feat(backend): add fastify api, auth, prisma schema and jobs
This commit is contained in:
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user