- 删除/新增模板后调用 ensureDay 对账当天,孩子端未完成的残留任务 立即同步移除,避免"孩子有任务但任务管理没有" - 已清理历史残留:来源模板已删除的未完成快照 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
7.2 KiB
TypeScript
203 lines
7.2 KiB
TypeScript
"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 { ensureDay } from "@/lib/tasks";
|
|
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<CreateCodeState> {
|
|
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<number>`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,
|
|
});
|
|
await ensureDay(parsed.data.childId, todayStr());
|
|
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, childUserId: taskTemplates.childUserId })
|
|
.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));
|
|
await ensureDay(row.childUserId, todayStr());
|
|
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");
|
|
}
|
|
|
|
// ===== 孩子管理:增/删 =====
|
|
export async function addChild(formData: FormData): Promise<string | null> {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") return "请先以家长身份登录";
|
|
const name = String(formData.get("name") ?? "").trim();
|
|
if (!name) return "请填写孩子称呼";
|
|
const [mx] = await db
|
|
.select({ m: sql<number>`COALESCE(MAX(${users.sortOrder}), -1)` })
|
|
.from(users)
|
|
.where(eq(users.familyId, parent.familyId));
|
|
await db.insert(users).values({
|
|
familyId: parent.familyId,
|
|
role: "child",
|
|
displayName: name,
|
|
sortOrder: mx.m + 1,
|
|
});
|
|
revalidatePath("/parent");
|
|
revalidatePath("/learn");
|
|
return null;
|
|
}
|
|
|
|
export async function removeChild(childId: number): Promise<void> {
|
|
const parent = await getSessionUser();
|
|
if (!parent || parent.role !== "parent") return;
|
|
const [row] = 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 (!row) return;
|
|
await db.delete(users).where(eq(users.id, childId));
|
|
revalidatePath("/parent");
|
|
revalidatePath("/learn");
|
|
}
|