"use server"; import crypto from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/db"; import { activationCodes, sessions, taskInstances, taskTemplates, users } from "@/db/schema"; import { SUBJECT_VALUES } from "@/db/schema"; import { todayStr } from "@/lib/dates"; import { getSessionUser } from "@/lib/auth"; function hashCode(code: string) { return crypto.createHash("sha256").update(code).digest("hex"); } export type CreateCodeState = { code?: string; childName?: string; error?: string; } | null; // 家长为指定孩子生成一次性激活码(15 分钟有效) export async function createActivationCode( _prev: CreateCodeState, formData: FormData, ): Promise { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") return { error: "请先以家长身份登录" }; const childId = Number(formData.get("childId")); const [child] = await db .select() .from(users) .where(eq(users.id, childId)) .limit(1); if (!child || child.role !== "child" || child.familyId !== parent.familyId) { return { error: "孩子不存在" }; } const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0"); await db.insert(activationCodes).values({ childUserId: child.id, codeHash: hashCode(code), createdBy: parent.id, expiresAt: new Date(Date.now() + 15 * 60_000), }); return { code, childName: child.displayName }; } // 家长补卡 / 撤销补卡:仅限过去的日期,补卡记录永久带标记 export async function makeupTask(instanceId: number, done: boolean) { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录"); const [row] = await db .select({ date: taskInstances.date, familyId: users.familyId }) .from(taskInstances) .innerJoin(users, eq(users.id, taskInstances.childUserId)) .where(eq(taskInstances.id, instanceId)) .limit(1); if (!row || row.familyId !== parent.familyId) throw new Error("任务不存在"); if (row.date >= todayStr()) throw new Error("只能对过去的日期补卡"); await db .update(taskInstances) .set({ status: done ? "done" : "pending", isMakeup: done, doneAt: done ? new Date() : null }) .where(eq(taskInstances.id, instanceId)); revalidatePath("/parent"); } // ===== 周模板管理 ===== const templateSchema = z.object({ childId: z.coerce.number().int().positive(), dow: z.coerce.number().int().min(0).max(6).catch(0), subject: z.enum(SUBJECT_VALUES).catch("other"), mode: z.enum(["weekly", "date", "range", "week"]).catch("weekly"), dateStart: z.string().nullish(), dateEnd: z.string().nullish(), title: z.string().trim().min(1, "任务内容不能为空").max(100, "最多 100 字"), requiresPhoto: z.boolean(), }); async function requireOwnerOfChild(childId: number) { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录"); const [child] = await db .select({ id: users.id }) .from(users) .where(and(eq(users.id, childId), eq(users.familyId, parent.familyId), eq(users.role, "child"))) .limit(1); if (!child) throw new Error("孩子不存在"); return parent; } // 添加模板任务(次日及以后生效,不改动已有历史) export async function addTemplate(formData: FormData) { const parsed = templateSchema.safeParse({ childId: formData.get("childId"), dow: formData.get("dow"), subject: formData.get("subject"), mode: formData.get("mode"), dateStart: formData.get("dateStart") || null, dateEnd: formData.get("dateEnd") || null, title: formData.get("title"), requiresPhoto: formData.get("requiresPhoto") === "on", }); if (!parsed.success) throw new Error(parsed.error.issues[0].message); await requireOwnerOfChild(parsed.data.childId); const [maxRow] = await db .select({ maxSort: sql`COALESCE(MAX(${taskTemplates.sortOrder}), -1)` }) .from(taskTemplates) .where(eq(taskTemplates.childUserId, parsed.data.childId)); await db.insert(taskTemplates).values({ childUserId: parsed.data.childId, dow: parsed.data.dow, subject: parsed.data.subject, mode: parsed.data.mode, dateStart: parsed.data.dateStart || null, dateEnd: parsed.data.dateEnd || null, title: parsed.data.title, requiresPhoto: parsed.data.requiresPhoto, sortOrder: maxRow.maxSort + 1, }); revalidatePath("/parent"); } // 删除模板任务(同样不影响已生成的历史任务) export async function deleteTemplate(templateId: number) { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录"); const [row] = await db .select({ id: taskTemplates.id }) .from(taskTemplates) .innerJoin(users, eq(users.id, taskTemplates.childUserId)) .where(and(eq(taskTemplates.id, templateId), eq(users.familyId, parent.familyId))) .limit(1); if (!row) throw new Error("任务不存在"); await db.delete(taskTemplates).where(eq(taskTemplates.id, templateId)); revalidatePath("/parent"); } // ===== 设备管理 ===== // 注销某台设备的会话(孩子那台 iPad 下次打开需要重新激活) export async function revokeSession(sessionId: number) { const parent = await getSessionUser(); if (!parent || parent.role !== "parent") throw new Error("请先以家长身份登录"); const [row] = await db .select({ id: sessions.id }) .from(sessions) .innerJoin(users, eq(users.id, sessions.userId)) .where(and(eq(sessions.id, sessionId), eq(users.familyId, parent.familyId))) .limit(1); if (!row) throw new Error("会话不存在"); await db.delete(sessions).where(eq(sessions.id, sessionId)); revalidatePath("/parent"); }