Checkpoint: v3.0 - 新增训练视频教程库(分类浏览、自评系统)、训练提醒通知(多类型提醒、浏览器推送)、通知记录管理、去除冗余文字。65个测试全部通过。
这个提交包含在:
231
server/db.ts
231
server/db.ts
@@ -10,6 +10,10 @@ import {
|
||||
ratingHistory, InsertRatingHistory,
|
||||
dailyCheckins, InsertDailyCheckin,
|
||||
userBadges, InsertUserBadge,
|
||||
tutorialVideos, InsertTutorialVideo,
|
||||
tutorialProgress, InsertTutorialProgress,
|
||||
trainingReminders, InsertTrainingReminder,
|
||||
notificationLog, InsertNotificationLog,
|
||||
} from "../drizzle/schema";
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
@@ -429,6 +433,233 @@ export async function getLeaderboard(sortBy: "ntrpRating" | "totalMinutes" | "to
|
||||
}).from(users).orderBy(desc(sortColumn)).limit(limit);
|
||||
}
|
||||
|
||||
// ===== TUTORIAL OPERATIONS =====
|
||||
|
||||
export const TUTORIAL_SEED_DATA: Omit<InsertTutorialVideo, "id">[] = [
|
||||
{
|
||||
title: "正手击球基础",
|
||||
category: "forehand",
|
||||
skillLevel: "beginner",
|
||||
description: "学习正手击球的基本站位、握拍方式和挥拍轨迹,建立稳定的正手基础。",
|
||||
keyPoints: JSON.stringify(["东方式或半西方式握拍", "侧身引拍,肩膀转动90度", "从低到高的挥拍轨迹", "随挥至对侧肩膀", "重心转移从后脚到前脚"]),
|
||||
commonMistakes: JSON.stringify(["手腕过度发力", "没有转体", "击球点太靠后", "随挥不充分"]),
|
||||
duration: 300,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
title: "反手击球基础",
|
||||
category: "backhand",
|
||||
skillLevel: "beginner",
|
||||
description: "掌握单手和双手反手的核心技术,包括握拍转换和击球时机。",
|
||||
keyPoints: JSON.stringify(["双手反手更适合初学者", "早引拍,肩膀充分转动", "击球点在身体前方", "保持手臂伸展"]),
|
||||
commonMistakes: JSON.stringify(["只用手臂发力", "击球点太迟", "缺少随挥", "脚步不到位"]),
|
||||
duration: 300,
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
title: "发球技术",
|
||||
category: "serve",
|
||||
skillLevel: "beginner",
|
||||
description: "从抛球、引拍到击球的完整发球动作分解与练习。",
|
||||
keyPoints: JSON.stringify(["稳定的抛球是关键", "大陆式握拍", "引拍时身体充分弓身", "最高点击球", "手腕内旋加速"]),
|
||||
commonMistakes: JSON.stringify(["抛球不稳定", "手臂弯曲击球", "重心没有向前", "发力时机不对"]),
|
||||
duration: 360,
|
||||
sortOrder: 3,
|
||||
},
|
||||
{
|
||||
title: "截击技术",
|
||||
category: "volley",
|
||||
skillLevel: "intermediate",
|
||||
description: "网前截击的站位、准备姿势和击球技巧。",
|
||||
keyPoints: JSON.stringify(["分腿弯膝准备姿势", "拍头保持在视线前方", "短促的击球动作", "步伐迎向球"]),
|
||||
commonMistakes: JSON.stringify(["挥拍幅度太大", "站位太远", "拍面角度不对", "重心太高"]),
|
||||
duration: 240,
|
||||
sortOrder: 4,
|
||||
},
|
||||
{
|
||||
title: "脚步移动训练",
|
||||
category: "footwork",
|
||||
skillLevel: "beginner",
|
||||
description: "网球基础脚步训练,包括分步、交叉步、滑步和回位。",
|
||||
keyPoints: JSON.stringify(["分步判断球的方向", "交叉步快速移动", "小碎步调整位置", "击球后快速回中"]),
|
||||
commonMistakes: JSON.stringify(["脚步懒散不移动", "重心太高", "回位太慢", "没有分步"]),
|
||||
duration: 240,
|
||||
sortOrder: 5,
|
||||
},
|
||||
{
|
||||
title: "正手上旋",
|
||||
category: "forehand",
|
||||
skillLevel: "intermediate",
|
||||
description: "掌握正手上旋球的发力技巧和拍面角度控制。",
|
||||
keyPoints: JSON.stringify(["半西方式或西方式握拍", "从低到高的刷球动作", "加速手腕内旋", "随挥结束在头部上方"]),
|
||||
commonMistakes: JSON.stringify(["拍面太开放", "没有刷球动作", "随挥不充分"]),
|
||||
duration: 300,
|
||||
sortOrder: 6,
|
||||
},
|
||||
{
|
||||
title: "发球变化(切削/上旋)",
|
||||
category: "serve",
|
||||
skillLevel: "advanced",
|
||||
description: "高级发球技术,包括切削发球和Kick发球的动作要领。",
|
||||
keyPoints: JSON.stringify(["切削发球:侧旋切球", "Kick发球:从下到上刷球", "抛球位置根据发球类型调整", "手腕加速是关键"]),
|
||||
commonMistakes: JSON.stringify(["抛球位置没有变化", "旋转不足", "发力方向错误"]),
|
||||
duration: 360,
|
||||
sortOrder: 7,
|
||||
},
|
||||
{
|
||||
title: "影子挥拍练习",
|
||||
category: "shadow",
|
||||
skillLevel: "beginner",
|
||||
description: "不需要球的挥拍练习,专注于动作轨迹和肌肉记忆。",
|
||||
keyPoints: JSON.stringify(["慢动作分解每个环节", "关注脚步和重心转移", "对着镜子检查姿势", "逐渐加快速度"]),
|
||||
commonMistakes: JSON.stringify(["动作太快不规范", "忽略脚步", "没有完整的随挥"]),
|
||||
duration: 180,
|
||||
sortOrder: 8,
|
||||
},
|
||||
{
|
||||
title: "墙壁练习技巧",
|
||||
category: "wall",
|
||||
skillLevel: "beginner",
|
||||
description: "利用墙壁进行的各种练习方法,提升控球和反应能力。",
|
||||
keyPoints: JSON.stringify(["保持适当距离", "控制力量和方向", "交替练习正反手", "注意脚步移动"]),
|
||||
commonMistakes: JSON.stringify(["力量太大控制不住", "站位太近或太远", "只练习一种击球"]),
|
||||
duration: 240,
|
||||
sortOrder: 9,
|
||||
},
|
||||
{
|
||||
title: "体能训练",
|
||||
category: "fitness",
|
||||
skillLevel: "beginner",
|
||||
description: "网球专项体能训练,提升爆发力、敏捷性和耐力。",
|
||||
keyPoints: JSON.stringify(["核心力量训练", "下肢爆发力练习", "敏捷性梯子训练", "拉伸和灵活性"]),
|
||||
commonMistakes: JSON.stringify(["忽略热身", "训练过度", "动作不标准"]),
|
||||
duration: 300,
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
title: "比赛策略基础",
|
||||
category: "strategy",
|
||||
skillLevel: "intermediate",
|
||||
description: "网球比赛中的基本战术和策略运用。",
|
||||
keyPoints: JSON.stringify(["控制球场深度", "变换节奏和方向", "利用对手弱点", "网前战术时机"]),
|
||||
commonMistakes: JSON.stringify(["打法单一", "没有计划", "心态波动大"]),
|
||||
duration: 300,
|
||||
sortOrder: 11,
|
||||
},
|
||||
];
|
||||
|
||||
export async function seedTutorials() {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
const existing = await db.select().from(tutorialVideos).limit(1);
|
||||
if (existing.length > 0) return; // Already seeded
|
||||
for (const t of TUTORIAL_SEED_DATA) {
|
||||
await db.insert(tutorialVideos).values(t);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTutorials(category?: string, skillLevel?: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
let conditions = [eq(tutorialVideos.isPublished, 1)];
|
||||
if (category) conditions.push(eq(tutorialVideos.category, category));
|
||||
if (skillLevel) conditions.push(eq(tutorialVideos.skillLevel, skillLevel as any));
|
||||
return db.select().from(tutorialVideos).where(and(...conditions)).orderBy(tutorialVideos.sortOrder);
|
||||
}
|
||||
|
||||
export async function getTutorialById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(tutorialVideos).where(eq(tutorialVideos.id, id)).limit(1);
|
||||
return result.length > 0 ? result[0] : undefined;
|
||||
}
|
||||
|
||||
export async function getUserTutorialProgress(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(tutorialProgress).where(eq(tutorialProgress.userId, userId));
|
||||
}
|
||||
|
||||
export async function updateTutorialProgress(userId: number, tutorialId: number, data: { watched?: number; selfScore?: number; notes?: string; comparisonVideoId?: number }) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
const existing = await db.select().from(tutorialProgress)
|
||||
.where(and(eq(tutorialProgress.userId, userId), eq(tutorialProgress.tutorialId, tutorialId)))
|
||||
.limit(1);
|
||||
if (existing.length > 0) {
|
||||
await db.update(tutorialProgress).set(data).where(eq(tutorialProgress.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(tutorialProgress).values({ userId, tutorialId, ...data });
|
||||
}
|
||||
}
|
||||
|
||||
// ===== TRAINING REMINDER OPERATIONS =====
|
||||
|
||||
export async function getUserReminders(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(trainingReminders).where(eq(trainingReminders.userId, userId)).orderBy(desc(trainingReminders.createdAt));
|
||||
}
|
||||
|
||||
export async function createReminder(data: InsertTrainingReminder) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(trainingReminders).values(data);
|
||||
return result[0].insertId;
|
||||
}
|
||||
|
||||
export async function updateReminder(reminderId: number, userId: number, data: Partial<InsertTrainingReminder>) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(trainingReminders).set(data).where(and(eq(trainingReminders.id, reminderId), eq(trainingReminders.userId, userId)));
|
||||
}
|
||||
|
||||
export async function deleteReminder(reminderId: number, userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(trainingReminders).where(and(eq(trainingReminders.id, reminderId), eq(trainingReminders.userId, userId)));
|
||||
}
|
||||
|
||||
export async function toggleReminder(reminderId: number, userId: number, isActive: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(trainingReminders).set({ isActive }).where(and(eq(trainingReminders.id, reminderId), eq(trainingReminders.userId, userId)));
|
||||
}
|
||||
|
||||
// ===== NOTIFICATION OPERATIONS =====
|
||||
|
||||
export async function getUserNotifications(userId: number, limit = 50) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(notificationLog).where(eq(notificationLog.userId, userId)).orderBy(desc(notificationLog.createdAt)).limit(limit);
|
||||
}
|
||||
|
||||
export async function createNotification(data: InsertNotificationLog) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.insert(notificationLog).values(data);
|
||||
}
|
||||
|
||||
export async function markNotificationRead(notificationId: number, userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(notificationLog).set({ isRead: 1 }).where(and(eq(notificationLog.id, notificationId), eq(notificationLog.userId, userId)));
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(notificationLog).set({ isRead: 1 }).where(eq(notificationLog.userId, userId));
|
||||
}
|
||||
|
||||
export async function getUnreadNotificationCount(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return 0;
|
||||
const result = await db.select({ count: sql<number>`count(*)` }).from(notificationLog)
|
||||
.where(and(eq(notificationLog.userId, userId), eq(notificationLog.isRead, 0)));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
// ===== STATS HELPERS =====
|
||||
|
||||
export async function getUserStats(userId: number) {
|
||||
|
||||
@@ -554,3 +554,222 @@ describe("BADGE_DEFINITIONS via badge.definitions endpoint", () => {
|
||||
expect(badges.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== TUTORIAL TESTS =====
|
||||
|
||||
describe("tutorial.list", () => {
|
||||
it("works without authentication (public)", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
try {
|
||||
const result = await caller.tutorial.list({});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
} catch (e: any) {
|
||||
// DB error expected, but should not be auth error
|
||||
expect(e.code).not.toBe("UNAUTHORIZED");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts category filter", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
try {
|
||||
await caller.tutorial.list({ category: "forehand" });
|
||||
} catch (e: any) {
|
||||
expect(e.message).not.toContain("invalid_type");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts skillLevel filter", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
try {
|
||||
await caller.tutorial.list({ skillLevel: "beginner" });
|
||||
} catch (e: any) {
|
||||
expect(e.message).not.toContain("invalid_type");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("tutorial.progress", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.tutorial.progress()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tutorial.updateProgress input validation", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(
|
||||
caller.tutorial.updateProgress({ tutorialId: 1 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("requires tutorialId", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(
|
||||
caller.tutorial.updateProgress({ tutorialId: undefined as any })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("accepts optional watched, selfScore, notes", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
try {
|
||||
await caller.tutorial.updateProgress({
|
||||
tutorialId: 1,
|
||||
watched: 1,
|
||||
selfScore: 4,
|
||||
notes: "Great tutorial",
|
||||
});
|
||||
} catch (e: any) {
|
||||
expect(e.message).not.toContain("invalid_type");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===== REMINDER TESTS =====
|
||||
|
||||
describe("reminder.list", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.reminder.list()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reminder.create input validation", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(
|
||||
caller.reminder.create({
|
||||
reminderType: "training",
|
||||
title: "Test",
|
||||
timeOfDay: "08:00",
|
||||
daysOfWeek: [1, 2, 3],
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("accepts empty title (no min validation on server)", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
try {
|
||||
await caller.reminder.create({
|
||||
reminderType: "training",
|
||||
title: "",
|
||||
timeOfDay: "08:00",
|
||||
daysOfWeek: [1],
|
||||
});
|
||||
} catch (e: any) {
|
||||
// DB error expected, but input validation should pass
|
||||
expect(e.message).not.toContain("invalid_type");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts any string as reminderType (no enum validation on server)", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
try {
|
||||
await caller.reminder.create({
|
||||
reminderType: "custom_type",
|
||||
title: "Test",
|
||||
timeOfDay: "08:00",
|
||||
daysOfWeek: [1],
|
||||
});
|
||||
} catch (e: any) {
|
||||
expect(e.message).not.toContain("invalid_type");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts valid reminder creation", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
try {
|
||||
await caller.reminder.create({
|
||||
reminderType: "training",
|
||||
title: "Morning Training",
|
||||
message: "Time to practice!",
|
||||
timeOfDay: "08:00",
|
||||
daysOfWeek: [1, 2, 3, 4, 5],
|
||||
});
|
||||
} catch (e: any) {
|
||||
expect(e.message).not.toContain("invalid_type");
|
||||
expect(e.message).not.toContain("invalid_enum_value");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("reminder.toggle input validation", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(
|
||||
caller.reminder.toggle({ reminderId: 1, isActive: 1 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reminder.delete input validation", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(
|
||||
caller.reminder.delete({ reminderId: 1 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== NOTIFICATION TESTS =====
|
||||
|
||||
describe("notification.list", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.notification.list()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification.unreadCount", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.notification.unreadCount()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification.markRead input validation", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(
|
||||
caller.notification.markRead({ notificationId: 1 })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification.markAllRead", () => {
|
||||
it("requires authentication", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.notification.markAllRead()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -471,6 +471,120 @@ ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(rec
|
||||
return db.getLeaderboard(input?.sortBy || "ntrpRating", input?.limit || 50);
|
||||
}),
|
||||
}),
|
||||
|
||||
// Tutorial video library
|
||||
tutorial: router({
|
||||
list: publicProcedure
|
||||
.input(z.object({
|
||||
category: z.string().optional(),
|
||||
skillLevel: z.string().optional(),
|
||||
}).optional())
|
||||
.query(async ({ input }) => {
|
||||
// Auto-seed tutorials on first request
|
||||
await db.seedTutorials();
|
||||
return db.getTutorials(input?.category, input?.skillLevel);
|
||||
}),
|
||||
|
||||
get: publicProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
return db.getTutorialById(input.id);
|
||||
}),
|
||||
|
||||
progress: protectedProcedure.query(async ({ ctx }) => {
|
||||
return db.getUserTutorialProgress(ctx.user.id);
|
||||
}),
|
||||
|
||||
updateProgress: protectedProcedure
|
||||
.input(z.object({
|
||||
tutorialId: z.number(),
|
||||
watched: z.number().optional(),
|
||||
selfScore: z.number().optional(),
|
||||
notes: z.string().optional(),
|
||||
comparisonVideoId: z.number().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { tutorialId, ...data } = input;
|
||||
await db.updateTutorialProgress(ctx.user.id, tutorialId, data);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// Training reminders
|
||||
reminder: router({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return db.getUserReminders(ctx.user.id);
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(z.object({
|
||||
reminderType: z.string(),
|
||||
title: z.string(),
|
||||
message: z.string().optional(),
|
||||
timeOfDay: z.string(),
|
||||
daysOfWeek: z.array(z.number()),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const reminderId = await db.createReminder({
|
||||
userId: ctx.user.id,
|
||||
...input,
|
||||
});
|
||||
return { reminderId };
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(z.object({
|
||||
reminderId: z.number(),
|
||||
title: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
timeOfDay: z.string().optional(),
|
||||
daysOfWeek: z.array(z.number()).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { reminderId, ...data } = input;
|
||||
await db.updateReminder(reminderId, ctx.user.id, data);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ reminderId: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db.deleteReminder(input.reminderId, ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
toggle: protectedProcedure
|
||||
.input(z.object({ reminderId: z.number(), isActive: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db.toggleReminder(input.reminderId, ctx.user.id, input.isActive);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// Notifications
|
||||
notification: router({
|
||||
list: protectedProcedure
|
||||
.input(z.object({ limit: z.number().default(50) }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
return db.getUserNotifications(ctx.user.id, input?.limit || 50);
|
||||
}),
|
||||
|
||||
unreadCount: protectedProcedure.query(async ({ ctx }) => {
|
||||
return db.getUnreadNotificationCount(ctx.user.id);
|
||||
}),
|
||||
|
||||
markRead: protectedProcedure
|
||||
.input(z.object({ notificationId: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db.markNotificationRead(input.notificationId, ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
markAllRead: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await db.markAllNotificationsRead(ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// NTRP Rating calculation function
|
||||
|
||||
在新工单中引用
屏蔽一个用户