评论与评分系统
This commit is contained in:
parent
c2d5948301
commit
65e483b626
@ -36,6 +36,7 @@
|
|||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"db:seed": "prisma db seed"
|
"db:seed": "prisma db seed",
|
||||||
|
"user:reset": "bun scripts/resetPassword.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
prisma/migrations/20250919131629_add_brief/migration.sql
Normal file
2
prisma/migrations/20250919131629_add_brief/migration.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "public"."CircuitModel" ADD COLUMN "brief" TEXT;
|
||||||
53
prisma/migrations/20250919141714_add_comment/migration.sql
Normal file
53
prisma/migrations/20250919141714_add_comment/migration.sql
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Comment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"modelId" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Rating" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"modelId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Rating_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Comment_modelId_idx" ON "public"."Comment"("modelId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Comment_authorId_idx" ON "public"."Comment"("authorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Comment_createdAt_idx" ON "public"."Comment"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Rating_modelId_idx" ON "public"."Rating"("modelId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Rating_userId_idx" ON "public"."Rating"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Rating_modelId_userId_key" ON "public"."Rating"("modelId", "userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Comment" ADD CONSTRAINT "Comment_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "public"."CircuitModel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Rating" ADD CONSTRAINT "Rating_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "public"."CircuitModel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Rating" ADD CONSTRAINT "Rating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -19,6 +19,8 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
models CircuitModel[]
|
models CircuitModel[]
|
||||||
|
comments Comment[]
|
||||||
|
ratings Rating[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CircuitModel {
|
model CircuitModel {
|
||||||
@ -27,12 +29,46 @@ model CircuitModel {
|
|||||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
authorId String
|
authorId String
|
||||||
desc String // markdown
|
desc String // markdown
|
||||||
|
brief String? // short summary for listings
|
||||||
model Json // JSON model data
|
model Json // JSON model data
|
||||||
preview Bytes? // binary preview image
|
preview Bytes? // binary preview image
|
||||||
previewMime String?
|
previewMime String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
comments Comment[]
|
||||||
|
ratings Rating[]
|
||||||
|
|
||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Comment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
model CircuitModel @relation(fields: [modelId], references: [id], onDelete: Cascade)
|
||||||
|
modelId String
|
||||||
|
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
|
authorId String
|
||||||
|
content String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([modelId])
|
||||||
|
@@index([authorId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Rating {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
model CircuitModel @relation(fields: [modelId], references: [id], onDelete: Cascade)
|
||||||
|
modelId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
value Int // 1-5
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([modelId, userId])
|
||||||
|
@@index([modelId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@ async function main() {
|
|||||||
data: {
|
data: {
|
||||||
title: s.title,
|
title: s.title,
|
||||||
desc: s.desc,
|
desc: s.desc,
|
||||||
model: s.model as any,
|
model: s.model,
|
||||||
preview: tinyPng,
|
preview: tinyPng,
|
||||||
previewMime: 'image/png',
|
previewMime: 'image/png',
|
||||||
authorId: user.id,
|
authorId: user.id,
|
||||||
|
|||||||
22
scripts/resetPassword.ts
Normal file
22
scripts/resetPassword.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import 'dotenv/config';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { prisma } from '../src/lib/prisma';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [username, newPassword] = process.argv.slice(2);
|
||||||
|
if (!username || !newPassword) {
|
||||||
|
console.error('Usage: bun scripts/resetPassword.ts <username> <newPassword>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const user = await prisma.user.findUnique({ where: { username } });
|
||||||
|
if (!user) {
|
||||||
|
console.error(`User not found: ${username}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const hash = await bcrypt.hash(newPassword, 10);
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { password: hash } });
|
||||||
|
console.log(`Password reset for user: ${username}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().finally(() => prisma.$disconnect());
|
||||||
@ -13,9 +13,15 @@ export const UpdateProfileSchema = z.object({
|
|||||||
avatarMime: z.string().optional(),
|
avatarMime: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ChangePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(6).max(128),
|
||||||
|
newPassword: z.string().min(6).max(128),
|
||||||
|
});
|
||||||
|
|
||||||
export const CreateModelSchema = z.object({
|
export const CreateModelSchema = z.object({
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
desc: z.string().min(1),
|
desc: z.string().min(1),
|
||||||
|
brief: z.string().min(1).max(300).optional(),
|
||||||
model: z.any(),
|
model: z.any(),
|
||||||
previewBase64: z.string().regex(base64Regex, 'Invalid base64').optional(),
|
previewBase64: z.string().regex(base64Regex, 'Invalid base64').optional(),
|
||||||
previewMime: z.string().optional(),
|
previewMime: z.string().optional(),
|
||||||
@ -30,3 +36,16 @@ export const ListQuerySchema = z.object({
|
|||||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
sort: z.enum(['new', 'old']).default('new'),
|
sort: z.enum(['new', 'old']).default('new'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CreateCommentSchema = z.object({
|
||||||
|
content: z.string().min(1).max(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ListCommentsQuerySchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SetRatingSchema = z.object({
|
||||||
|
value: z.coerce.number().int().min(1).max(5),
|
||||||
|
});
|
||||||
|
|||||||
@ -18,8 +18,10 @@ export async function basicAuth(req: Request, res: Response, next: NextFunction)
|
|||||||
}
|
}
|
||||||
const idx = decoded.indexOf(':');
|
const idx = decoded.indexOf(':');
|
||||||
if (idx === -1) return res.status(400).json({ error: 'Invalid Authorization payload' });
|
if (idx === -1) return res.status(400).json({ error: 'Invalid Authorization payload' });
|
||||||
const username = decoded.slice(0, idx);
|
const username = decoded.slice(0, idx);
|
||||||
const password = decoded.slice(idx + 1);
|
const password = decoded.slice(idx + 1);
|
||||||
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { username } });
|
const user = await prisma.user.findUnique({ where: { username } });
|
||||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
const ok = await bcrypt.compare(password, user.password);
|
const ok = await bcrypt.compare(password, user.password);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
import { basicAuth } from '../middlewares/basicAuth';
|
import { basicAuth } from '../middlewares/basicAuth';
|
||||||
import { CreateModelSchema, UpdateModelSchema, ListQuerySchema } from '../lib/validators';
|
import { CreateModelSchema, UpdateModelSchema, ListQuerySchema, CreateCommentSchema, ListCommentsQuerySchema, SetRatingSchema } from '../lib/validators';
|
||||||
import { decodeBase64ToBuffer } from '../lib/base64';
|
import { decodeBase64ToBuffer } from '../lib/base64';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
@ -12,7 +12,7 @@ const dirname = new URL('.', import.meta.url).pathname;
|
|||||||
async function generatePreviewImage(circuitModel: any) {
|
async function generatePreviewImage(circuitModel: any) {
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1024, height: 1024 });
|
await page.setViewportSize({ width: 1024, height: 1024 });
|
||||||
// Load the lab page
|
// Load the lab page
|
||||||
await page.goto(`http://localhost:${Number(process.env.PORT ?? 3000)}/lab?preview=true`, { waitUntil: 'load' });
|
await page.goto(`http://localhost:${Number(process.env.PORT ?? 3000)}/lab?preview=true`, { waitUntil: 'load' });
|
||||||
@ -37,11 +37,12 @@ export const router = Router();
|
|||||||
router.post('/', basicAuth, async (req: Request, res: Response) => {
|
router.post('/', basicAuth, async (req: Request, res: Response) => {
|
||||||
const parsed = CreateModelSchema.safeParse(req.body);
|
const parsed = CreateModelSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
const { title, desc, model } = parsed.data;
|
const { title, desc, brief, model } = parsed.data;
|
||||||
const created = await prisma.circuitModel.create({
|
const created = await prisma.circuitModel.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
desc,
|
desc,
|
||||||
|
brief,
|
||||||
model,
|
model,
|
||||||
preview: await generatePreviewImage(model),
|
preview: await generatePreviewImage(model),
|
||||||
previewMime: 'image/png',
|
previewMime: 'image/png',
|
||||||
@ -59,10 +60,11 @@ router.put('/:id', basicAuth, async (req: Request, res: Response) => {
|
|||||||
if (owned.authorId !== (req as any).user.id) return res.status(403).json({ error: 'Forbidden' });
|
if (owned.authorId !== (req as any).user.id) return res.status(403).json({ error: 'Forbidden' });
|
||||||
const parsed = UpdateModelSchema.safeParse(req.body);
|
const parsed = UpdateModelSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
const { title, desc, model, previewBase64, previewMime } = parsed.data;
|
const { title, desc, brief, model, previewBase64, previewMime } = parsed.data;
|
||||||
const data: any = {};
|
const data: any = {};
|
||||||
if (title !== undefined) data.title = title;
|
if (title !== undefined) data.title = title;
|
||||||
if (desc !== undefined) data.desc = desc;
|
if (desc !== undefined) data.desc = desc;
|
||||||
|
if (brief !== undefined) data.brief = brief;
|
||||||
if (model !== undefined) data.model = model;
|
if (model !== undefined) data.model = model;
|
||||||
if (previewBase64 !== undefined) {
|
if (previewBase64 !== undefined) {
|
||||||
data.preview = decodeBase64ToBuffer(previewBase64);
|
data.preview = decodeBase64ToBuffer(previewBase64);
|
||||||
@ -98,7 +100,17 @@ router.get('/:id', async (req: Request, res: Response) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!m) return res.status(404).json({ error: 'Not found' });
|
if (!m) return res.status(404).json({ error: 'Not found' });
|
||||||
res.json(m);
|
// include rating summary
|
||||||
|
const ratingSummary = await prisma.rating.aggregate({
|
||||||
|
where: { modelId: id },
|
||||||
|
_avg: { value: true },
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
...m,
|
||||||
|
avgRating: ratingSummary._avg.value ?? null,
|
||||||
|
ratingCount: (ratingSummary._count as number) ?? 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// List models with pagination and simple search
|
// List models with pagination and simple search
|
||||||
@ -124,9 +136,25 @@ router.get('/', async (req: Request, res: Response) => {
|
|||||||
authorId: true,
|
authorId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
|
desc: true,
|
||||||
|
brief: true
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
res.json({ page, pageSize, total, items });
|
// attach ratings summary per item (batch aggregations)
|
||||||
|
const ids = items.map(i => i.id);
|
||||||
|
const ratingAggs = await (prisma as any).rating.groupBy({
|
||||||
|
by: ['modelId'],
|
||||||
|
where: { modelId: { in: ids } },
|
||||||
|
_avg: { value: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const map: Record<string, { avgRating: number | null; ratingCount: number }> = {};
|
||||||
|
for (const r of ratingAggs) {
|
||||||
|
// @ts-ignore dynamic groupBy types
|
||||||
|
map[r.modelId as string] = { avgRating: (r._avg as any).value ?? null, ratingCount: (r._count as any)._all ?? 0 };
|
||||||
|
}
|
||||||
|
const enriched = items.map((i: any) => ({ ...i, avgRating: map[i.id]?.avgRating ?? null, ratingCount: map[i.id]?.ratingCount ?? 0 }));
|
||||||
|
res.json({ page, pageSize, total, items: enriched });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download model JSON
|
// Download model JSON
|
||||||
@ -147,3 +175,87 @@ router.get('/:id/preview', async (req: Request, res: Response) => {
|
|||||||
res.setHeader('Content-Type', m.previewMime ?? 'image/png');
|
res.setHeader('Content-Type', m.previewMime ?? 'image/png');
|
||||||
res.end(Buffer.from(m.preview));
|
res.end(Buffer.from(m.preview));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Comments: list
|
||||||
|
router.get('/:id/comments', async (req: Request, res: Response) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const parsed = ListCommentsQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
const { page, pageSize } = parsed.data;
|
||||||
|
const where = { modelId: id };
|
||||||
|
const total = await (prisma as any).comment.count({ where });
|
||||||
|
const items = await (prisma as any).comment.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
select: { id: true, content: true, createdAt: true, updatedAt: true, authorId: true, author: { select: { username: true } } },
|
||||||
|
});
|
||||||
|
res.json({ page, pageSize, total, items: items.map((i: any) => ({
|
||||||
|
id: i.id,
|
||||||
|
content: i.content,
|
||||||
|
createdAt: i.createdAt,
|
||||||
|
updatedAt: i.updatedAt,
|
||||||
|
authorId: i.authorId,
|
||||||
|
authorName: (i.author as any)?.username,
|
||||||
|
})) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Comments: create
|
||||||
|
router.post('/:id/comments', basicAuth, async (req: Request, res: Response) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const parsed = CreateCommentSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
const { content } = parsed.data;
|
||||||
|
// ensure model exists
|
||||||
|
const exists = await prisma.circuitModel.findUnique({ where: { id }, select: { id: true } });
|
||||||
|
if (!exists) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const created = await (prisma as any).comment.create({
|
||||||
|
data: { modelId: id, authorId: (req as any).user.id, content },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
res.status(201).json({ id: created.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Comments: delete (author only)
|
||||||
|
router.delete('/:id/comments/:commentId', basicAuth, async (req: Request, res: Response) => {
|
||||||
|
const { id, commentId } = req.params;
|
||||||
|
const c = await (prisma as any).comment.findUnique({ where: { id: commentId }, select: { authorId: true, modelId: true } });
|
||||||
|
if (!c || c.modelId !== id) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (c.authorId !== (req as any).user.id) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
await (prisma as any).comment.delete({ where: { id: commentId } });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rating: set/update current user's rating
|
||||||
|
router.post('/:id/rating', basicAuth, async (req: Request, res: Response) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const parsed = SetRatingSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
const { value } = parsed.data;
|
||||||
|
const userId = (req as any).user.id;
|
||||||
|
// upsert rating
|
||||||
|
const r = await (prisma as any).rating.upsert({
|
||||||
|
where: { modelId_userId: { modelId: id, userId } },
|
||||||
|
update: { value },
|
||||||
|
create: { modelId: id, userId, value },
|
||||||
|
select: { id: true, value: true },
|
||||||
|
});
|
||||||
|
res.json(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rating: get my rating and summary
|
||||||
|
router.get('/:id/rating', async (req: Request, res: Response) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const summary = await (prisma as any).rating.aggregate({ where: { modelId: id }, _avg: { value: true }, _count: true });
|
||||||
|
let my: number | null = null;
|
||||||
|
res.json({ avgRating: summary._avg.value ?? null, ratingCount: (summary._count as number) ?? 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// My rating (authenticated)
|
||||||
|
router.get('/:id/my-rating', basicAuth, async (req: Request, res: Response) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const userId = (req as any).user.id as string;
|
||||||
|
const r = await (prisma as any).rating.findUnique({ where: { modelId_userId: { modelId: id, userId } }, select: { value: true } });
|
||||||
|
res.json({ myRating: r?.value ?? null });
|
||||||
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import { prisma } from '../lib/prisma';
|
import { prisma } from '../lib/prisma';
|
||||||
import { RegisterSchema, UpdateProfileSchema } from '../lib/validators';
|
import { RegisterSchema, UpdateProfileSchema, ChangePasswordSchema } from '../lib/validators';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { basicAuth } from '../middlewares/basicAuth';
|
import { basicAuth } from '../middlewares/basicAuth';
|
||||||
import { decodeBase64ToBuffer } from '../lib/base64';
|
import { decodeBase64ToBuffer } from '../lib/base64';
|
||||||
@ -41,6 +41,20 @@ router.put('/me', basicAuth, async (req: Request, res: Response) => {
|
|||||||
res.json({ id: user.id, username: user.username, updatedAt: user.updatedAt });
|
res.json({ id: user.id, username: user.username, updatedAt: user.updatedAt });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
router.post('/change-password', basicAuth, async (req: Request, res: Response) => {
|
||||||
|
const parsed = ChangePasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
const { currentPassword, newPassword } = parsed.data;
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: (req as any).user.id } });
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
const ok = await bcrypt.compare(currentPassword, user.password);
|
||||||
|
if (!ok) return res.status(401).json({ error: 'Current password is incorrect' });
|
||||||
|
const hash = await bcrypt.hash(newPassword, 10);
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { password: hash } });
|
||||||
|
return res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Get avatar binary
|
// Get avatar binary
|
||||||
router.get('/:id/avatar', async (req: Request, res: Response) => {
|
router.get('/:id/avatar', async (req: Request, res: Response) => {
|
||||||
const u = await prisma.user.findUnique({ where: { id: req.params.id }, select: { avatar: true, avatarMime: true } });
|
const u = await prisma.user.findUnique({ where: { id: req.params.id }, select: { avatar: true, avatarMime: true } });
|
||||||
@ -48,3 +62,10 @@ router.get('/:id/avatar', async (req: Request, res: Response) => {
|
|||||||
res.setHeader('Content-Type', u.avatarMime ?? 'image/png');
|
res.setHeader('Content-Type', u.avatarMime ?? 'image/png');
|
||||||
res.end(Buffer.from(u.avatar));
|
res.end(Buffer.from(u.avatar));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get user by ID
|
||||||
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
|
const u = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!u) return res.status(404).json({ error: 'User not found' });
|
||||||
|
res.json({ id: u.id, username: u.username, createdAt: u.createdAt, updatedAt: u.updatedAt });
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user