Database: - Add missing indexes (users.credits, users_currency(type,amount), users_settings.respects_received, camera_web.timestamp, messenger_offline.user_id) via migrations 0020/0021 - Use partial .select() everywhere instead of SELECT * (tickets, users, rooms, audit logs, catalog tree, polls, radio, password reset) - Add queryPrepared/queryPreparedOne (server-side prepared statements) and switch the login check to a prepared statement; drop dead cache options from the pool config - Raise total_users/total_rooms COUNT(*) cache TTL to 5m Caching: - Consolidate the three cache helpers (cached, redisCache, cachedQuery) into a single memory-first implementation backed by Redis - invalidateKey now clears the in-process cache as well as Redis - Cache homepage sections, news list, and leaderboard tabs; share one news_list cache key between homepage and news archive - siteSettings: in-process cache with TTL so repeated getters no longer pay a Redis round-trip per call - Share a 10s poll cache across all radio SSE connections - Normalize timestamps after cache reads (Redis JSON round-trip) Assets: - Enable AVIF/WebP via images.formats and remove unoptimized from news covers and the homepage hero (149KB jpg) with proper sizes/priority - Support ?format=webp|avif|png in the /imaging proxy via sharp Other: - Fix pnpm supply-chain minimumReleaseAge failures by excluding the freshly-published packages (next 16.3.1, hookform resolvers 5.8.0, resend 6.20.0) - Remove unused before/after fields from housekeeping AuditEntry
225 lines
5.3 KiB
TypeScript
225 lines
5.3 KiB
TypeScript
"use server";
|
|
|
|
import { eq } from "drizzle-orm";
|
|
import { db, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
|
|
import { PERMS } from "@/lib/permissions";
|
|
import { adminAction, authAction } from "@/lib/safe-action";
|
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
|
import { logAudit } from "@/lib/services/audit";
|
|
import { notify } from "@/lib/services/webhook";
|
|
import {
|
|
assignTicketSchema,
|
|
createTicketSchema,
|
|
replyTicketSchema,
|
|
updateTicketPrioritySchema,
|
|
updateTicketStatusSchema,
|
|
} from "@/lib/validators/ticket";
|
|
|
|
// ── User actions (authenticated, no admin perms needed) ──────────────
|
|
|
|
export const createTicket = authAction(
|
|
{
|
|
schema: createTicketSchema,
|
|
rateLimitKey: "ticket-create",
|
|
rateLimitMax: 5,
|
|
rateLimitWindowMs: 60_000,
|
|
},
|
|
async (ctx) => {
|
|
const now = new Date();
|
|
const [result] = await db.insert(WebsiteTicket).values({
|
|
subject: ctx.data.subject,
|
|
category: ctx.data.category,
|
|
creatorId: ctx.session.user.id,
|
|
updatedAt: now,
|
|
});
|
|
const ticketId = Number(result.insertId);
|
|
|
|
// Create the first message
|
|
await db.insert(WebsiteTicketMessage).values({
|
|
ticketId,
|
|
userId: ctx.session.user.id,
|
|
message: ctx.data.message,
|
|
isStaff: 0,
|
|
});
|
|
|
|
notify({
|
|
action: "ticket_create",
|
|
actor: ctx.session.user.username,
|
|
target: `#${ticketId} - ${ctx.data.subject}`,
|
|
details: `Category: ${ctx.data.category}`,
|
|
});
|
|
|
|
return actionOk({ id: ticketId });
|
|
},
|
|
);
|
|
|
|
export const adminReplyTicket = adminAction(
|
|
{
|
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
|
schema: replyTicketSchema,
|
|
},
|
|
async (ctx) => {
|
|
const [ticket] = await db
|
|
.select({
|
|
id: WebsiteTicket.id,
|
|
assigneeId: WebsiteTicket.assigneeId,
|
|
})
|
|
.from(WebsiteTicket)
|
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
|
.limit(1);
|
|
|
|
if (!ticket) throw new ActionError("Ticket not found");
|
|
|
|
await db.insert(WebsiteTicketMessage).values({
|
|
ticketId: ctx.data.ticketId,
|
|
userId: ctx.session.user.id,
|
|
message: ctx.data.message,
|
|
isStaff: 1,
|
|
});
|
|
|
|
// Auto-assign if not assigned yet
|
|
const updates: Partial<typeof WebsiteTicket.$inferInsert> = {
|
|
status: "waiting",
|
|
updatedAt: new Date(),
|
|
};
|
|
if (!ticket.assigneeId) {
|
|
updates.assigneeId = ctx.session.user.id;
|
|
}
|
|
|
|
await db
|
|
.update(WebsiteTicket)
|
|
.set(updates)
|
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: "ticket_reply",
|
|
target: "WebsiteTicket",
|
|
targetId: ctx.data.ticketId,
|
|
});
|
|
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
export const updateTicketStatus = adminAction(
|
|
{
|
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
|
schema: updateTicketStatusSchema,
|
|
},
|
|
async (ctx) => {
|
|
const [ticket] = await db
|
|
.select({
|
|
id: WebsiteTicket.id,
|
|
assigneeId: WebsiteTicket.assigneeId,
|
|
status: WebsiteTicket.status,
|
|
})
|
|
.from(WebsiteTicket)
|
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
|
.limit(1);
|
|
|
|
if (!ticket) throw new ActionError("Ticket not found");
|
|
|
|
const data: Partial<typeof WebsiteTicket.$inferInsert> = {
|
|
status: ctx.data.status,
|
|
updatedAt: new Date(),
|
|
};
|
|
if (ctx.data.status === "closed") {
|
|
data.closedAt = new Date();
|
|
}
|
|
if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
|
|
data.assigneeId = ctx.session.user.id;
|
|
}
|
|
|
|
await db
|
|
.update(WebsiteTicket)
|
|
.set(data)
|
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: "ticket_status_change",
|
|
target: "WebsiteTicket",
|
|
targetId: ctx.data.ticketId,
|
|
before: { status: ticket.status },
|
|
after: { status: ctx.data.status },
|
|
});
|
|
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
export const assignTicket = adminAction(
|
|
{
|
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
|
schema: assignTicketSchema,
|
|
},
|
|
async (ctx) => {
|
|
const [ticket] = await db
|
|
.select({
|
|
id: WebsiteTicket.id,
|
|
assigneeId: WebsiteTicket.assigneeId,
|
|
})
|
|
.from(WebsiteTicket)
|
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
|
.limit(1);
|
|
|
|
if (!ticket) throw new ActionError("Ticket not found");
|
|
|
|
await db
|
|
.update(WebsiteTicket)
|
|
.set({
|
|
assigneeId: ctx.data.assigneeId,
|
|
status: ctx.data.assigneeId ? "in_progress" : "open",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: "ticket_assign",
|
|
target: "WebsiteTicket",
|
|
targetId: ctx.data.ticketId,
|
|
before: { assigneeId: ticket.assigneeId },
|
|
after: { assigneeId: ctx.data.assigneeId },
|
|
});
|
|
|
|
return actionOk();
|
|
},
|
|
);
|
|
|
|
export const updateTicketPriority = adminAction(
|
|
{
|
|
permission: [PERMS.TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT],
|
|
schema: updateTicketPrioritySchema,
|
|
},
|
|
async (ctx) => {
|
|
const [ticket] = await db
|
|
.select({
|
|
id: WebsiteTicket.id,
|
|
priority: WebsiteTicket.priority,
|
|
})
|
|
.from(WebsiteTicket)
|
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId))
|
|
.limit(1);
|
|
|
|
if (!ticket) throw new ActionError("Ticket not found");
|
|
|
|
await db
|
|
.update(WebsiteTicket)
|
|
.set({ priority: ctx.data.priority, updatedAt: new Date() })
|
|
.where(eq(WebsiteTicket.id, ctx.data.ticketId));
|
|
|
|
logAudit({
|
|
userId: ctx.session.user.id,
|
|
action: "ticket_priority_change",
|
|
target: "WebsiteTicket",
|
|
targetId: ctx.data.ticketId,
|
|
before: { priority: ticket.priority },
|
|
after: { priority: ctx.data.priority },
|
|
});
|
|
|
|
return actionOk();
|
|
},
|
|
);
|