feat(backend): add fastify api, auth, prisma schema and jobs
This commit is contained in:
6
apps/backend/.dockerignore
Normal file
6
apps/backend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
uploads
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
50
apps/backend/package.json
Normal file
50
apps/backend/package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "@family-wishlist/backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "echo 'skip'",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:push": "prisma db push",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||||
|
"prisma:studio": "prisma studio",
|
||||||
|
"seed": "tsx prisma/seed.ts",
|
||||||
|
"hash-password": "tsx scripts/hash-password.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@family-wishlist/shared": "workspace:*",
|
||||||
|
"@fastify/cookie": "^9.4.0",
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"@fastify/helmet": "^11.1.1",
|
||||||
|
"@fastify/jwt": "^8.0.1",
|
||||||
|
"@fastify/multipart": "^8.3.0",
|
||||||
|
"@fastify/rate-limit": "^9.1.0",
|
||||||
|
"@fastify/sensible": "^5.6.0",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
|
"@prisma/client": "^5.19.1",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"fastify": "^4.28.1",
|
||||||
|
"fastify-type-provider-zod": "^2.0.0",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"open-graph-scraper": "^6.8.3",
|
||||||
|
"pino-pretty": "^11.2.2",
|
||||||
|
"undici": "^6.19.8",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^20.16.5",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"prisma": "^5.19.1",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
81
apps/backend/prisma/schema.prisma
Normal file
81
apps/backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
// Users
|
||||||
|
//
|
||||||
|
// There are exactly two users in this application. Their credentials
|
||||||
|
// (username + bcrypt hash) live in env — see apps/backend/src/config/env.ts.
|
||||||
|
// The DB stores only "public" fields to scope wishes and serve public
|
||||||
|
// profiles. The password hash is intentionally NOT stored here; this keeps
|
||||||
|
// the single source of truth for credentials in env and limits the
|
||||||
|
// blast-radius of a DB dump.
|
||||||
|
// --------------------------------------------------------------------
|
||||||
|
model User {
|
||||||
|
id String @id
|
||||||
|
username String @unique
|
||||||
|
slug String @unique
|
||||||
|
displayName String
|
||||||
|
bio String?
|
||||||
|
avatarUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
wishes Wish[]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WishStatus {
|
||||||
|
ACTIVE
|
||||||
|
ARCHIVED
|
||||||
|
COMPLETED
|
||||||
|
DELETED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ImageSource {
|
||||||
|
DEFAULT
|
||||||
|
OG
|
||||||
|
UPLOADED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Wish {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
title String
|
||||||
|
price Decimal? @db.Decimal(12, 2)
|
||||||
|
currency String @default("RUB")
|
||||||
|
url String?
|
||||||
|
comment String?
|
||||||
|
imageUrl String?
|
||||||
|
imageSource ImageSource @default(DEFAULT)
|
||||||
|
status WishStatus @default(ACTIVE)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
archivedAt DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
deletedAt DateTime?
|
||||||
|
sourceWishId String?
|
||||||
|
|
||||||
|
views GuestView[]
|
||||||
|
|
||||||
|
@@index([userId, status])
|
||||||
|
@@index([deletedAt])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model GuestView {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guestId String
|
||||||
|
wishId String
|
||||||
|
wish Wish @relation(fields: [wishId], references: [id], onDelete: Cascade)
|
||||||
|
seenAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([guestId, wishId])
|
||||||
|
@@index([guestId])
|
||||||
|
}
|
||||||
37
apps/backend/prisma/seed.ts
Normal file
37
apps/backend/prisma/seed.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { resolveUsers } from '../src/config/env.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const users = resolveUsers();
|
||||||
|
for (const u of users) {
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { username: u.username },
|
||||||
|
update: {
|
||||||
|
id: u.id,
|
||||||
|
slug: u.slug,
|
||||||
|
displayName: u.displayName,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
slug: u.slug,
|
||||||
|
displayName: u.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`seeded user: ${u.username} (slug=${u.slug}, id=${u.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
})
|
||||||
|
.catch(async (err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(err);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
27
apps/backend/scripts/hash-password.ts
Normal file
27
apps/backend/scripts/hash-password.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Local CLI helper — produces a bcrypt hash for an env USER*_PASSWORD_HASH value.
|
||||||
|
// Usage:
|
||||||
|
// pnpm hash-password "mySuperSecret"
|
||||||
|
//
|
||||||
|
// The plain password is only present in argv/RAM during this invocation.
|
||||||
|
// It is NOT logged anywhere; only the hash is printed to stdout. Copy it into .env.
|
||||||
|
|
||||||
|
import { hashPassword } from '../src/utils/password.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const raw = process.argv.slice(2).join(' ').trim();
|
||||||
|
if (!raw) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Usage: pnpm hash-password "<password>"');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (raw.length < 8) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('Password must be at least 8 characters.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const hash = await hashPassword(raw);
|
||||||
|
// Print ONLY the hash, nothing else, so it is trivial to redirect/copy.
|
||||||
|
process.stdout.write(hash + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
86
apps/backend/src/app.ts
Normal file
86
apps/backend/src/app.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import helmet from '@fastify/helmet';
|
||||||
|
import sensible from '@fastify/sensible';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import { HttpError } from './utils/errors.js';
|
||||||
|
|
||||||
|
import prismaPlugin from './plugins/prisma.js';
|
||||||
|
import corsPlugin from './plugins/cors.js';
|
||||||
|
import rateLimitPlugin from './plugins/rate-limit.js';
|
||||||
|
import authPlugin from './plugins/auth.js';
|
||||||
|
import guestPlugin from './plugins/guest.js';
|
||||||
|
import staticPlugin from './plugins/static.js';
|
||||||
|
import multipartPlugin from './plugins/multipart.js';
|
||||||
|
|
||||||
|
import authRoutes from './modules/auth/auth.routes.js';
|
||||||
|
import profileRoutes from './modules/profile/profile.routes.js';
|
||||||
|
import wishesRoutes from './modules/wishes/wishes.routes.js';
|
||||||
|
import imagesRoutes from './modules/images/images.routes.js';
|
||||||
|
import publicRoutes from './modules/public/public.routes.js';
|
||||||
|
import metaRoutes from './modules/meta/meta.routes.js';
|
||||||
|
import { registerPurgeTrashJob } from './jobs/purge-trash.js';
|
||||||
|
|
||||||
|
export async function buildApp(): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: env.LOG_LEVEL,
|
||||||
|
transport:
|
||||||
|
env.NODE_ENV === 'development'
|
||||||
|
? { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss', singleLine: true } }
|
||||||
|
: undefined,
|
||||||
|
redact: {
|
||||||
|
paths: ['req.body.password', 'req.headers.cookie', 'req.headers.authorization'],
|
||||||
|
remove: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trustProxy: true,
|
||||||
|
bodyLimit: 1 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setErrorHandler((err, request, reply) => {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
return reply.code(err.statusCode).send({
|
||||||
|
error: err.code,
|
||||||
|
message: err.message,
|
||||||
|
details: err.details,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'VALIDATION',
|
||||||
|
message: 'Invalid input',
|
||||||
|
details: err.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((err as { statusCode?: number }).statusCode === 429) {
|
||||||
|
return reply.code(429).send({ error: 'RATE_LIMITED', message: err.message });
|
||||||
|
}
|
||||||
|
request.log.error({ err }, 'Unhandled error');
|
||||||
|
return reply.code(500).send({ error: 'INTERNAL', message: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(helmet, {
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
|
});
|
||||||
|
await app.register(sensible);
|
||||||
|
await app.register(corsPlugin);
|
||||||
|
await app.register(rateLimitPlugin);
|
||||||
|
await app.register(authPlugin);
|
||||||
|
await app.register(guestPlugin);
|
||||||
|
await app.register(staticPlugin);
|
||||||
|
await app.register(multipartPlugin);
|
||||||
|
await app.register(prismaPlugin);
|
||||||
|
|
||||||
|
await app.register(metaRoutes, { prefix: '/api' });
|
||||||
|
await app.register(authRoutes, { prefix: '/api/auth' });
|
||||||
|
await app.register(profileRoutes, { prefix: '/api/profile' });
|
||||||
|
await app.register(wishesRoutes, { prefix: '/api/wishes' });
|
||||||
|
await app.register(imagesRoutes, { prefix: '/api/wishes' });
|
||||||
|
await app.register(publicRoutes, { prefix: '/api/public' });
|
||||||
|
|
||||||
|
registerPurgeTrashJob(app);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
30
apps/backend/src/auth/users.registry.ts
Normal file
30
apps/backend/src/auth/users.registry.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { resolveUsers } from '../config/env.js';
|
||||||
|
import type { RegistryUser } from './users.registry.types.js';
|
||||||
|
|
||||||
|
// Built once on process start from env. Source of truth for credentials.
|
||||||
|
// DB contains only "public" copies (id, username, slug, displayName) — never the hash.
|
||||||
|
const users: RegistryUser[] = resolveUsers();
|
||||||
|
const byUsername = new Map(users.map((u) => [u.username, u] as const));
|
||||||
|
const byId = new Map(users.map((u) => [u.id, u] as const));
|
||||||
|
|
||||||
|
export const usersRegistry = {
|
||||||
|
all(): readonly RegistryUser[] {
|
||||||
|
return users;
|
||||||
|
},
|
||||||
|
findByUsername(username: string): RegistryUser | undefined {
|
||||||
|
return byUsername.get(username);
|
||||||
|
},
|
||||||
|
findById(id: string): RegistryUser | undefined {
|
||||||
|
return byId.get(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-computed bcrypt hash of a random string, used for timing-safe compare
|
||||||
|
// when the requested username does not exist. Generated lazily on first need.
|
||||||
|
let dummyHashCache: string | null = null;
|
||||||
|
export async function getDummyHash(): Promise<string> {
|
||||||
|
if (dummyHashCache) return dummyHashCache;
|
||||||
|
const { default: bcrypt } = await import('bcryptjs');
|
||||||
|
dummyHashCache = await bcrypt.hash('__not_a_real_password__', 10);
|
||||||
|
return dummyHashCache;
|
||||||
|
}
|
||||||
7
apps/backend/src/auth/users.registry.types.ts
Normal file
7
apps/backend/src/auth/users.registry.types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface RegistryUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
passwordHash: string;
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
89
apps/backend/src/config/env.ts
Normal file
89
apps/backend/src/config/env.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
BACKEND_PORT: z.coerce.number().int().positive().default(3000),
|
||||||
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
|
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
PUBLIC_APP_URL: z.string().url().default('http://localhost:8080'),
|
||||||
|
UPLOADS_DIR: z.string().default('./uploads'),
|
||||||
|
|
||||||
|
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
|
||||||
|
COOKIE_SECRET: z.string().min(32, 'COOKIE_SECRET must be at least 32 chars'),
|
||||||
|
|
||||||
|
USER1_USERNAME: z.string().min(3).max(64),
|
||||||
|
USER1_PASSWORD_HASH: z.string().min(20, 'USER1_PASSWORD_HASH must be a bcrypt hash'),
|
||||||
|
USER1_SLUG: z.string().min(3).max(32),
|
||||||
|
USER1_DISPLAY_NAME: z.string().min(1).max(64),
|
||||||
|
|
||||||
|
USER2_USERNAME: z.string().min(3).max(64),
|
||||||
|
USER2_PASSWORD_HASH: z.string().min(20, 'USER2_PASSWORD_HASH must be a bcrypt hash'),
|
||||||
|
USER2_SLUG: z.string().min(3).max(32),
|
||||||
|
USER2_DISPLAY_NAME: z.string().min(1).max(64),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
function parseEnv(): Env {
|
||||||
|
const parsed = envSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('\nInvalid environment configuration:\n');
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(` - ${issue.path.join('.')}: ${issue.message}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = parseEnv();
|
||||||
|
|
||||||
|
export interface EnvUserConfig {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
passwordHash: string;
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableUserId(username: string): string {
|
||||||
|
// 24-char stable id derived from username so DB seed can upsert deterministically
|
||||||
|
// without depending on any external secret.
|
||||||
|
return 'u_' + crypto.createHash('sha256').update(`user:${username}`).digest('hex').slice(0, 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUsers(): EnvUserConfig[] {
|
||||||
|
const usernames = new Set<string>();
|
||||||
|
const slugs = new Set<string>();
|
||||||
|
const users: EnvUserConfig[] = [
|
||||||
|
{
|
||||||
|
id: stableUserId(env.USER1_USERNAME),
|
||||||
|
username: env.USER1_USERNAME,
|
||||||
|
passwordHash: env.USER1_PASSWORD_HASH,
|
||||||
|
slug: env.USER1_SLUG,
|
||||||
|
displayName: env.USER1_DISPLAY_NAME,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: stableUserId(env.USER2_USERNAME),
|
||||||
|
username: env.USER2_USERNAME,
|
||||||
|
passwordHash: env.USER2_PASSWORD_HASH,
|
||||||
|
slug: env.USER2_SLUG,
|
||||||
|
displayName: env.USER2_DISPLAY_NAME,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const u of users) {
|
||||||
|
if (usernames.has(u.username)) {
|
||||||
|
throw new Error(`Duplicate USER*_USERNAME: ${u.username}`);
|
||||||
|
}
|
||||||
|
if (slugs.has(u.slug)) {
|
||||||
|
throw new Error(`Duplicate USER*_SLUG: ${u.slug}`);
|
||||||
|
}
|
||||||
|
usernames.add(u.username);
|
||||||
|
slugs.add(u.slug);
|
||||||
|
}
|
||||||
|
return users;
|
||||||
|
}
|
||||||
32
apps/backend/src/index.ts
Normal file
32
apps/backend/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { buildApp } from './app.js';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const app = await buildApp();
|
||||||
|
try {
|
||||||
|
await app.listen({ port: env.BACKEND_PORT, host: '0.0.0.0' });
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err }, 'failed to start');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shutdown = async (signal: NodeJS.Signals) => {
|
||||||
|
app.log.info({ signal }, 'shutting down');
|
||||||
|
try {
|
||||||
|
await app.close();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err }, 'shutdown error');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
void shutdown('SIGTERM');
|
||||||
|
});
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
void shutdown('SIGINT');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
48
apps/backend/src/jobs/purge-trash.ts
Normal file
48
apps/backend/src/jobs/purge-trash.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { TRASH_RETENTION_DAYS } from '@family-wishlist/shared';
|
||||||
|
import { deleteLocalImageIfAny } from '../modules/images/storage.service.js';
|
||||||
|
|
||||||
|
async function purge(app: FastifyInstance): Promise<number> {
|
||||||
|
const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
const victims = await app.prisma.wish.findMany({
|
||||||
|
where: { status: 'DELETED', deletedAt: { lt: cutoff } },
|
||||||
|
select: { id: true, imageUrl: true },
|
||||||
|
});
|
||||||
|
if (victims.length === 0) return 0;
|
||||||
|
|
||||||
|
await Promise.all(victims.map((v) => deleteLocalImageIfAny(v.imageUrl)));
|
||||||
|
const res = await app.prisma.wish.deleteMany({
|
||||||
|
where: { id: { in: victims.map((v) => v.id) } },
|
||||||
|
});
|
||||||
|
return res.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPurgeTrashJob(app: FastifyInstance): void {
|
||||||
|
// Run daily at 03:17 (chosen to avoid common cron rush).
|
||||||
|
const task = cron.schedule(
|
||||||
|
'17 3 * * *',
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const count = await purge(app);
|
||||||
|
if (count > 0) app.log.info({ count }, 'trash: purged expired wishes');
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err }, 'trash: purge failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scheduled: true, timezone: 'UTC' },
|
||||||
|
);
|
||||||
|
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
task.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also run once on startup to catch up if backend was offline for a while.
|
||||||
|
setTimeout(() => {
|
||||||
|
purge(app)
|
||||||
|
.then((count) => {
|
||||||
|
if (count > 0) app.log.info({ count }, 'trash: startup purge');
|
||||||
|
})
|
||||||
|
.catch((err) => app.log.error({ err }, 'trash: startup purge failed'));
|
||||||
|
}, 5_000);
|
||||||
|
}
|
||||||
43
apps/backend/src/modules/auth/auth.routes.ts
Normal file
43
apps/backend/src/modules/auth/auth.routes.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { loginSchema } from '@family-wishlist/shared';
|
||||||
|
import { verifyCredentials } from './auth.service.js';
|
||||||
|
import { usersRegistry } from '../../auth/users.registry.js';
|
||||||
|
import { UnauthorizedError } from '../../utils/errors.js';
|
||||||
|
|
||||||
|
export default async function authRoutes(app: FastifyInstance) {
|
||||||
|
app.post(
|
||||||
|
'/login',
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
rateLimit: { max: 5, timeWindow: '10 minutes' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = loginSchema.parse(request.body);
|
||||||
|
const user = await verifyCredentials(body.username, body.password);
|
||||||
|
const token = await reply.jwtSign({ id: user.id, username: user.username });
|
||||||
|
app.setAuthCookie(reply, token);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
slug: user.slug,
|
||||||
|
displayName: user.displayName,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post('/logout', async (_request, reply) => {
|
||||||
|
app.clearAuthCookie(reply);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/me',
|
||||||
|
{ preHandler: [app.authenticate] },
|
||||||
|
async (request) => {
|
||||||
|
const u = usersRegistry.findById(request.user.id);
|
||||||
|
if (!u) throw new UnauthorizedError();
|
||||||
|
return { id: u.id, username: u.username, slug: u.slug, displayName: u.displayName };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/backend/src/modules/auth/auth.service.ts
Normal file
25
apps/backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { verifyPassword } from '../../utils/password.js';
|
||||||
|
import { getDummyHash, usersRegistry } from '../../auth/users.registry.js';
|
||||||
|
import { InvalidCredentialsError } from '../../utils/errors.js';
|
||||||
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCredentials(
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<AuthenticatedUser> {
|
||||||
|
const user = usersRegistry.findByUsername(username);
|
||||||
|
// Always run bcrypt.compare to keep response time stable regardless of whether
|
||||||
|
// the username exists. Otherwise an attacker could enumerate usernames by timing.
|
||||||
|
const hash = user?.passwordHash ?? (await getDummyHash());
|
||||||
|
const ok = await verifyPassword(password, hash);
|
||||||
|
if (!user || !ok) {
|
||||||
|
throw new InvalidCredentialsError();
|
||||||
|
}
|
||||||
|
return { id: user.id, username: user.username, slug: user.slug, displayName: user.displayName };
|
||||||
|
}
|
||||||
50
apps/backend/src/modules/images/images.routes.ts
Normal file
50
apps/backend/src/modules/images/images.routes.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { MAX_UPLOAD_BYTES } from '../../plugins/multipart.js';
|
||||||
|
import { WishesService } from '../wishes/wishes.service.js';
|
||||||
|
import { ValidationError } from '../../utils/errors.js';
|
||||||
|
import { deleteLocalImageIfAny, saveUploadedImage } from './storage.service.js';
|
||||||
|
import { fetchOgImageForWish } from './og.service.js';
|
||||||
|
|
||||||
|
export default async function imagesRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook('preHandler', app.authenticate);
|
||||||
|
const wishes = new WishesService(app.prisma);
|
||||||
|
|
||||||
|
app.post('/:id/image', async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const current = await wishes.getOwned(request.user.id, id);
|
||||||
|
|
||||||
|
const data = await request.file();
|
||||||
|
if (!data) throw new ValidationError('No file uploaded');
|
||||||
|
|
||||||
|
const buffer = await data.toBuffer();
|
||||||
|
if (buffer.byteLength > MAX_UPLOAD_BYTES) {
|
||||||
|
throw new ValidationError('File too large');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imageUrl } = await saveUploadedImage(id, data.mimetype, buffer);
|
||||||
|
await deleteLocalImageIfAny(current.imageUrl);
|
||||||
|
|
||||||
|
return app.prisma.wish.update({
|
||||||
|
where: { id },
|
||||||
|
data: { imageUrl, imageSource: 'UPLOADED' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/:id/image/refresh-og', async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const wish = await wishes.getOwned(request.user.id, id);
|
||||||
|
if (!wish.url) throw new ValidationError('Wish has no url');
|
||||||
|
await fetchOgImageForWish(app, id, wish.url);
|
||||||
|
return app.prisma.wish.findUniqueOrThrow({ where: { id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/:id/image', async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const wish = await wishes.getOwned(request.user.id, id);
|
||||||
|
await deleteLocalImageIfAny(wish.imageUrl);
|
||||||
|
return app.prisma.wish.update({
|
||||||
|
where: { id },
|
||||||
|
data: { imageUrl: null, imageSource: 'DEFAULT' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
93
apps/backend/src/modules/images/og.service.ts
Normal file
93
apps/backend/src/modules/images/og.service.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { request as undiciRequest } from 'undici';
|
||||||
|
import ogs from 'open-graph-scraper';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve, extname } from 'node:path';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { env } from '../../config/env.js';
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||||
|
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||||
|
const FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
interface DownloadResult {
|
||||||
|
buffer: Buffer;
|
||||||
|
ext: string;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImage(url: string): Promise<DownloadResult | null> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await undiciRequest(url, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { 'user-agent': 'FamilyWishlistBot/1.0 (+image-fetch)' },
|
||||||
|
});
|
||||||
|
if (res.statusCode >= 400) return null;
|
||||||
|
const contentType = (res.headers['content-type']?.toString() ?? '').split(';')[0].trim();
|
||||||
|
if (!ALLOWED_MIME.has(contentType)) return null;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for await (const chunk of res.body) {
|
||||||
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
total += buf.length;
|
||||||
|
if (total > MAX_IMAGE_BYTES) return null;
|
||||||
|
chunks.push(buf);
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
const extFromCt = contentType.split('/')[1] ?? 'jpg';
|
||||||
|
const extFromUrl = extname(new URL(url).pathname).replace('.', '').toLowerCase();
|
||||||
|
const ext = ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(extFromUrl)
|
||||||
|
? extFromUrl
|
||||||
|
: extFromCt;
|
||||||
|
return { buffer, ext, contentType };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOgImageForWish(
|
||||||
|
app: FastifyInstance,
|
||||||
|
wishId: string,
|
||||||
|
pageUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parsed = await ogs({ url: pageUrl, timeout: FETCH_TIMEOUT_MS });
|
||||||
|
if (parsed.error || !parsed.result) return;
|
||||||
|
const imageEntry = parsed.result.ogImage;
|
||||||
|
const imageUrl = Array.isArray(imageEntry) ? imageEntry[0]?.url : imageEntry?.url;
|
||||||
|
if (!imageUrl) return;
|
||||||
|
|
||||||
|
const absolute = new URL(imageUrl, pageUrl).toString();
|
||||||
|
const dl = await downloadImage(absolute);
|
||||||
|
if (!dl) return;
|
||||||
|
|
||||||
|
const filename = `${wishId}-${nanoid(8)}.${dl.ext}`;
|
||||||
|
const absPath = resolve(env.UPLOADS_DIR, 'og', filename);
|
||||||
|
await writeFile(absPath, dl.buffer);
|
||||||
|
|
||||||
|
const current = await app.prisma.wish.findUnique({ where: { id: wishId } });
|
||||||
|
if (!current) return;
|
||||||
|
if (current.imageSource === 'UPLOADED') return; // do not overwrite user upload
|
||||||
|
|
||||||
|
await app.prisma.wish.update({
|
||||||
|
where: { id: wishId },
|
||||||
|
data: { imageUrl: `/uploads/og/${filename}`, imageSource: 'OG' },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err, wishId, pageUrl }, 'OG image fetch failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueOgFetch(app: FastifyInstance, wishId: string, pageUrl: string): void {
|
||||||
|
// Fire-and-forget. Errors are swallowed inside fetchOgImageForWish.
|
||||||
|
setImmediate(() => {
|
||||||
|
void fetchOgImageForWish(app, wishId, pageUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
38
apps/backend/src/modules/images/storage.service.ts
Normal file
38
apps/backend/src/modules/images/storage.service.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { writeFile, unlink } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { env } from '../../config/env.js';
|
||||||
|
import { ValidationError } from '../../utils/errors.js';
|
||||||
|
|
||||||
|
const MIME_TO_EXT: Record<string, string> = {
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function saveUploadedImage(
|
||||||
|
wishId: string,
|
||||||
|
mime: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
): Promise<{ imageUrl: string }> {
|
||||||
|
const ext = MIME_TO_EXT[mime];
|
||||||
|
if (!ext) throw new ValidationError('Unsupported image type');
|
||||||
|
const filename = `${wishId}-${nanoid(8)}.${ext}`;
|
||||||
|
const relative = `/uploads/upload/${filename}`;
|
||||||
|
const absPath = resolve(env.UPLOADS_DIR, 'upload', filename);
|
||||||
|
await writeFile(absPath, buffer);
|
||||||
|
return { imageUrl: relative };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLocalImageIfAny(imageUrl: string | null): Promise<void> {
|
||||||
|
if (!imageUrl) return;
|
||||||
|
if (!imageUrl.startsWith('/uploads/')) return;
|
||||||
|
const rel = imageUrl.replace(/^\/uploads\//, '');
|
||||||
|
const absPath = resolve(env.UPLOADS_DIR, rel);
|
||||||
|
try {
|
||||||
|
await unlink(absPath);
|
||||||
|
} catch {
|
||||||
|
// already gone — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/backend/src/modules/meta/meta.routes.ts
Normal file
7
apps/backend/src/modules/meta/meta.routes.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { getBackendVersion } from '../../utils/version.js';
|
||||||
|
|
||||||
|
export default async function metaRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/version', async () => ({ backend: getBackendVersion() }));
|
||||||
|
app.get('/health', async () => ({ status: 'ok', ts: new Date().toISOString() }));
|
||||||
|
}
|
||||||
30
apps/backend/src/modules/profile/profile.routes.ts
Normal file
30
apps/backend/src/modules/profile/profile.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { updateProfileSchema } from '@family-wishlist/shared';
|
||||||
|
import { ConflictError, NotFoundError } from '../../utils/errors.js';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export default async function profileRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook('preHandler', app.authenticate);
|
||||||
|
|
||||||
|
app.get('/', async (request) => {
|
||||||
|
const profile = await app.prisma.user.findUnique({ where: { id: request.user.id } });
|
||||||
|
if (!profile) throw new NotFoundError('Profile');
|
||||||
|
return profile;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/', async (request) => {
|
||||||
|
const body = updateProfileSchema.parse(request.body);
|
||||||
|
try {
|
||||||
|
const updated = await app.prisma.user.update({
|
||||||
|
where: { id: request.user.id },
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||||
|
throw new ConflictError('Slug is already taken');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
58
apps/backend/src/modules/public/public.routes.ts
Normal file
58
apps/backend/src/modules/public/public.routes.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { markSeenSchema } from '@family-wishlist/shared';
|
||||||
|
import { NotFoundError } from '../../utils/errors.js';
|
||||||
|
|
||||||
|
export default async function publicRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/:slug', async (request) => {
|
||||||
|
const { slug } = request.params as { slug: string };
|
||||||
|
const user = await app.prisma.user.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { slug: true, displayName: true, bio: true, avatarUrl: true },
|
||||||
|
});
|
||||||
|
if (!user) throw new NotFoundError('Profile');
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/:slug/wishes', async (request) => {
|
||||||
|
const { slug } = request.params as { slug: string };
|
||||||
|
const user = await app.prisma.user.findUnique({ where: { slug }, select: { id: true } });
|
||||||
|
if (!user) throw new NotFoundError('Profile');
|
||||||
|
|
||||||
|
const wishes = await app.prisma.wish.findMany({
|
||||||
|
where: { userId: user.id, status: { in: ['ACTIVE', 'COMPLETED'] } },
|
||||||
|
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wishIds = wishes.map((w) => w.id);
|
||||||
|
const seen = wishIds.length
|
||||||
|
? await app.prisma.guestView.findMany({
|
||||||
|
where: { guestId: request.guestId, wishId: { in: wishIds } },
|
||||||
|
select: { wishId: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const seenSet = new Set(seen.map((s) => s.wishId));
|
||||||
|
return wishes.map((w) => ({
|
||||||
|
...w,
|
||||||
|
isNewForGuest: w.status === 'ACTIVE' && !seenSet.has(w.id),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/:slug/views', async (request) => {
|
||||||
|
const { slug } = request.params as { slug: string };
|
||||||
|
const body = markSeenSchema.parse(request.body);
|
||||||
|
|
||||||
|
const user = await app.prisma.user.findUnique({ where: { slug }, select: { id: true } });
|
||||||
|
if (!user) throw new NotFoundError('Profile');
|
||||||
|
|
||||||
|
// Filter wishIds to those that actually belong to this user (avoid cross-user pollution).
|
||||||
|
const owned = await app.prisma.wish.findMany({
|
||||||
|
where: { userId: user.id, id: { in: body.wishIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (owned.length === 0) return { marked: 0 };
|
||||||
|
|
||||||
|
const data = owned.map((w) => ({ guestId: request.guestId, wishId: w.id }));
|
||||||
|
const res = await app.prisma.guestView.createMany({ data, skipDuplicates: true });
|
||||||
|
return { marked: res.count };
|
||||||
|
});
|
||||||
|
}
|
||||||
75
apps/backend/src/modules/wishes/wishes.routes.ts
Normal file
75
apps/backend/src/modules/wishes/wishes.routes.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import {
|
||||||
|
createWishSchema,
|
||||||
|
updateWishSchema,
|
||||||
|
wishStatusQuery,
|
||||||
|
NEW_BADGE_DAYS,
|
||||||
|
} from '@family-wishlist/shared';
|
||||||
|
import { WishesService } from './wishes.service.js';
|
||||||
|
import { enqueueOgFetch } from '../images/og.service.js';
|
||||||
|
|
||||||
|
export default async function wishesRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook('preHandler', app.authenticate);
|
||||||
|
const service = new WishesService(app.prisma);
|
||||||
|
|
||||||
|
app.get('/', async (request) => {
|
||||||
|
const qs = wishStatusQuery.parse((request.query as { status?: string })?.status ?? 'active');
|
||||||
|
const wishes = await service.list(request.user.id, qs);
|
||||||
|
return wishes.map((w) => ({
|
||||||
|
...w,
|
||||||
|
isNewForOwner:
|
||||||
|
w.status === 'ACTIVE' &&
|
||||||
|
Date.now() - w.createdAt.getTime() < NEW_BADGE_DAYS * 24 * 60 * 60 * 1000,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/:id', async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return service.getOwned(request.user.id, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/', async (request, reply) => {
|
||||||
|
const input = createWishSchema.parse(request.body);
|
||||||
|
const wish = await service.create(request.user.id, input);
|
||||||
|
if (wish.url) enqueueOgFetch(app, wish.id, wish.url);
|
||||||
|
reply.code(201);
|
||||||
|
return wish;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/:id', async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const input = updateWishSchema.parse(request.body);
|
||||||
|
const updated = await service.update(request.user.id, id, input);
|
||||||
|
if (input.url !== undefined && updated.url) {
|
||||||
|
enqueueOgFetch(app, updated.id, updated.url);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/:id', async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return service.softDelete(request.user.id, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/:id/archive', async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return service.archive(request.user.id, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/:id/complete', async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return service.complete(request.user.id, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/:id/restore', async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return service.restore(request.user.id, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/:id/duplicate', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const wish = await service.duplicate(request.user.id, id);
|
||||||
|
reply.code(201);
|
||||||
|
return wish;
|
||||||
|
});
|
||||||
|
}
|
||||||
117
apps/backend/src/modules/wishes/wishes.service.ts
Normal file
117
apps/backend/src/modules/wishes/wishes.service.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { PrismaClient, Wish, WishStatus } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
ConflictError,
|
||||||
|
ForbiddenError,
|
||||||
|
NotFoundError,
|
||||||
|
} from '../../utils/errors.js';
|
||||||
|
import type { CreateWishInput, UpdateWishInput } from '@family-wishlist/shared';
|
||||||
|
|
||||||
|
type Status = 'active' | 'archived' | 'completed' | 'deleted';
|
||||||
|
|
||||||
|
const statusMap: Record<Status, WishStatus> = {
|
||||||
|
active: 'ACTIVE',
|
||||||
|
archived: 'ARCHIVED',
|
||||||
|
completed: 'COMPLETED',
|
||||||
|
deleted: 'DELETED',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WishesService {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
list(userId: string, status: Status): Promise<Wish[]> {
|
||||||
|
return this.prisma.wish.findMany({
|
||||||
|
where: { userId, status: statusMap[status] },
|
||||||
|
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOwned(userId: string, id: string): Promise<Wish> {
|
||||||
|
const wish = await this.prisma.wish.findUnique({ where: { id } });
|
||||||
|
if (!wish) throw new NotFoundError('Wish');
|
||||||
|
if (wish.userId !== userId) throw new ForbiddenError();
|
||||||
|
return wish;
|
||||||
|
}
|
||||||
|
|
||||||
|
create(userId: string, input: CreateWishInput): Promise<Wish> {
|
||||||
|
return this.prisma.wish.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
title: input.title,
|
||||||
|
price: input.price ?? null,
|
||||||
|
currency: input.currency ?? 'RUB',
|
||||||
|
url: input.url ?? null,
|
||||||
|
comment: input.comment ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(userId: string, id: string, input: UpdateWishInput): Promise<Wish> {
|
||||||
|
await this.getOwned(userId, id);
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (input.title !== undefined) data.title = input.title;
|
||||||
|
if (input.price !== undefined) data.price = input.price ?? null;
|
||||||
|
if (input.currency !== undefined) data.currency = input.currency ?? 'RUB';
|
||||||
|
if (input.url !== undefined) data.url = input.url ?? null;
|
||||||
|
if (input.comment !== undefined) data.comment = input.comment ?? null;
|
||||||
|
return this.prisma.wish.update({ where: { id }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(userId: string, id: string): Promise<Wish> {
|
||||||
|
const wish = await this.getOwned(userId, id);
|
||||||
|
if (wish.status === 'ARCHIVED') return wish;
|
||||||
|
if (wish.status === 'DELETED') {
|
||||||
|
throw new ConflictError('Cannot archive a deleted wish; restore it first');
|
||||||
|
}
|
||||||
|
return this.prisma.wish.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'ARCHIVED', archivedAt: new Date(), completedAt: null, deletedAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(userId: string, id: string): Promise<Wish> {
|
||||||
|
const wish = await this.getOwned(userId, id);
|
||||||
|
if (wish.status === 'COMPLETED') return wish;
|
||||||
|
if (wish.status === 'DELETED') {
|
||||||
|
throw new ConflictError('Cannot complete a deleted wish; restore it first');
|
||||||
|
}
|
||||||
|
return this.prisma.wish.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'COMPLETED', completedAt: new Date(), archivedAt: null, deletedAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(userId: string, id: string): Promise<Wish> {
|
||||||
|
const wish = await this.getOwned(userId, id);
|
||||||
|
if (wish.status === 'DELETED') return wish;
|
||||||
|
return this.prisma.wish.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'DELETED', deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(userId: string, id: string): Promise<Wish> {
|
||||||
|
const wish = await this.getOwned(userId, id);
|
||||||
|
if (wish.status === 'ACTIVE') return wish;
|
||||||
|
return this.prisma.wish.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'ACTIVE', archivedAt: null, completedAt: null, deletedAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async duplicate(userId: string, id: string): Promise<Wish> {
|
||||||
|
const source = await this.getOwned(userId, id);
|
||||||
|
return this.prisma.wish.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
title: source.title,
|
||||||
|
price: source.price,
|
||||||
|
currency: source.currency,
|
||||||
|
url: source.url,
|
||||||
|
comment: source.comment,
|
||||||
|
imageUrl: source.imageSource === 'UPLOADED' ? null : source.imageUrl,
|
||||||
|
imageSource: source.imageSource === 'UPLOADED' ? 'DEFAULT' : source.imageSource,
|
||||||
|
sourceWishId: source.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/backend/src/plugins/auth.ts
Normal file
51
apps/backend/src/plugins/auth.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import fastifyJwt from '@fastify/jwt';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { UnauthorizedError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
export const AUTH_COOKIE = 'fw_auth';
|
||||||
|
const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
||||||
|
|
||||||
|
export default fp(async (app) => {
|
||||||
|
await app.register(fastifyCookie, {
|
||||||
|
secret: env.COOKIE_SECRET,
|
||||||
|
parseOptions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(fastifyJwt, {
|
||||||
|
secret: env.JWT_SECRET,
|
||||||
|
cookie: { cookieName: AUTH_COOKIE, signed: false },
|
||||||
|
sign: { expiresIn: `${AUTH_COOKIE_MAX_AGE}s` },
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate('authenticate', async (request) => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify({ onlyCookie: true });
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// helpers for routes
|
||||||
|
app.decorate('setAuthCookie', ((reply, token) => {
|
||||||
|
reply.setCookie(AUTH_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: AUTH_COOKIE_MAX_AGE,
|
||||||
|
});
|
||||||
|
}) as never);
|
||||||
|
|
||||||
|
app.decorate('clearAuthCookie', ((reply) => {
|
||||||
|
reply.clearCookie(AUTH_COOKIE, { path: '/' });
|
||||||
|
}) as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
setAuthCookie: (reply: import('fastify').FastifyReply, token: string) => void;
|
||||||
|
clearAuthCookie: (reply: import('fastify').FastifyReply) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/backend/src/plugins/cors.ts
Normal file
10
apps/backend/src/plugins/cors.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import fastifyCors from '@fastify/cors';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export default fp(async (app) => {
|
||||||
|
await app.register(fastifyCors, {
|
||||||
|
origin: env.NODE_ENV === 'production' ? env.PUBLIC_APP_URL : true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
25
apps/backend/src/plugins/guest.ts
Normal file
25
apps/backend/src/plugins/guest.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export const GUEST_COOKIE = 'fw_gid';
|
||||||
|
const GUEST_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2; // 2 years
|
||||||
|
|
||||||
|
export default fp(async (app) => {
|
||||||
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
|
const existing = request.cookies[GUEST_COOKIE];
|
||||||
|
if (existing && existing.length >= 16 && existing.length <= 64) {
|
||||||
|
request.guestId = existing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = nanoid(24);
|
||||||
|
request.guestId = id;
|
||||||
|
reply.setCookie(GUEST_COOKIE, id, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: GUEST_COOKIE_MAX_AGE,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
13
apps/backend/src/plugins/multipart.ts
Normal file
13
apps/backend/src/plugins/multipart.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import fastifyMultipart from '@fastify/multipart';
|
||||||
|
|
||||||
|
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||||
|
|
||||||
|
export default fp(async (app) => {
|
||||||
|
await app.register(fastifyMultipart, {
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_UPLOAD_BYTES,
|
||||||
|
files: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
18
apps/backend/src/plugins/prisma.ts
Normal file
18
apps/backend/src/plugins/prisma.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import fp from 'fastify-plugin';
|
||||||
|
|
||||||
|
export default fp(async (app) => {
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
log: [{ emit: 'event', level: 'error' }, { emit: 'event', level: 'warn' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
prisma.$on('error', (e) => app.log.error({ prisma: e }, 'prisma error'));
|
||||||
|
prisma.$on('warn', (e) => app.log.warn({ prisma: e }, 'prisma warn'));
|
||||||
|
|
||||||
|
await prisma.$connect();
|
||||||
|
app.decorate('prisma', prisma);
|
||||||
|
|
||||||
|
app.addHook('onClose', async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
8
apps/backend/src/plugins/rate-limit.ts
Normal file
8
apps/backend/src/plugins/rate-limit.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import fastifyRateLimit from '@fastify/rate-limit';
|
||||||
|
|
||||||
|
export default fp(async (app) => {
|
||||||
|
await app.register(fastifyRateLimit, {
|
||||||
|
global: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
20
apps/backend/src/plugins/static.ts
Normal file
20
apps/backend/src/plugins/static.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import fp from 'fastify-plugin';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export default fp(async (app) => {
|
||||||
|
const uploadsRoot = resolve(env.UPLOADS_DIR);
|
||||||
|
mkdirSync(resolve(uploadsRoot, 'og'), { recursive: true });
|
||||||
|
mkdirSync(resolve(uploadsRoot, 'upload'), { recursive: true });
|
||||||
|
mkdirSync(resolve(uploadsRoot, 'avatar'), { recursive: true });
|
||||||
|
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: uploadsRoot,
|
||||||
|
prefix: '/uploads/',
|
||||||
|
decorateReply: false,
|
||||||
|
index: false,
|
||||||
|
list: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
22
apps/backend/src/types/fastify.d.ts
vendored
Normal file
22
apps/backend/src/types/fastify.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import type { RegistryUser } from '../auth/users.registry.types.js';
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyInstance {
|
||||||
|
prisma: PrismaClient;
|
||||||
|
authenticate: (request: import('fastify').FastifyRequest) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FastifyRequest {
|
||||||
|
user: { id: string; username: string };
|
||||||
|
authUser?: RegistryUser;
|
||||||
|
guestId: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@fastify/jwt' {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: { id: string; username: string };
|
||||||
|
user: { id: string; username: string };
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/backend/src/utils/errors.ts
Normal file
49
apps/backend/src/utils/errors.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export class HttpError extends Error {
|
||||||
|
readonly statusCode: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly details?: unknown;
|
||||||
|
|
||||||
|
constructor(statusCode: number, code: string, message: string, details?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'HttpError';
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidCredentialsError extends HttpError {
|
||||||
|
constructor() {
|
||||||
|
super(401, 'INVALID_CREDENTIALS', 'Invalid username or password');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends HttpError {
|
||||||
|
constructor(message = 'Not authenticated') {
|
||||||
|
super(401, 'UNAUTHORIZED', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends HttpError {
|
||||||
|
constructor(what = 'Resource') {
|
||||||
|
super(404, 'NOT_FOUND', `${what} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends HttpError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(409, 'CONFLICT', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends HttpError {
|
||||||
|
constructor(message: string, details?: unknown) {
|
||||||
|
super(400, 'VALIDATION', message, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends HttpError {
|
||||||
|
constructor(message = 'Forbidden') {
|
||||||
|
super(403, 'FORBIDDEN', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/backend/src/utils/password.ts
Normal file
11
apps/backend/src/utils/password.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export const BCRYPT_ROUNDS = 12;
|
||||||
|
|
||||||
|
export async function hashPassword(plain: string): Promise<string> {
|
||||||
|
return bcrypt.hash(plain, BCRYPT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(plain, hash);
|
||||||
|
}
|
||||||
31
apps/backend/src/utils/version.ts
Normal file
31
apps/backend/src/utils/version.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
let cached: string | null = null;
|
||||||
|
|
||||||
|
export function getBackendVersion(): string {
|
||||||
|
if (cached) return cached;
|
||||||
|
// Walk up until we find apps/backend/package.json.
|
||||||
|
const candidates = [
|
||||||
|
resolve(__dirname, '../../package.json'),
|
||||||
|
resolve(__dirname, '../../../package.json'),
|
||||||
|
resolve(process.cwd(), 'package.json'),
|
||||||
|
];
|
||||||
|
for (const p of candidates) {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(p, 'utf-8');
|
||||||
|
const pkg = JSON.parse(raw) as { name?: string; version?: string };
|
||||||
|
if (pkg.name === '@family-wishlist/backend' && pkg.version) {
|
||||||
|
cached = pkg.version;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cached = '0.0.0';
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
10
apps/backend/tsconfig.build.json
Normal file
10
apps/backend/tsconfig.build.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["**/*.test.ts", "dist"]
|
||||||
|
}
|
||||||
16
apps/backend/tsconfig.json
Normal file
16
apps/backend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"rootDir": "./",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "scripts/**/*", "prisma/seed.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user