From fd1e9018eeb2b2731f8c3ef966f79f56d6f87311 Mon Sep 17 00:00:00 2001 From: Ivan Botygin Date: Thu, 20 Nov 2025 20:24:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(saves):=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=83=D1=89=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D1=85=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/saves.controller.ts | 57 +++++++++++++++++++ apps/backend/src/services/saves.service.ts | 47 +++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/apps/backend/src/controllers/saves.controller.ts b/apps/backend/src/controllers/saves.controller.ts index 02dc9b7..ecb840a 100644 --- a/apps/backend/src/controllers/saves.controller.ts +++ b/apps/backend/src/controllers/saves.controller.ts @@ -175,6 +175,63 @@ export const savesController = new Elysia({ prefix: '/saves' }) } ) + .patch( + '/:id', + async ({ params: { id }, body, user, set }) => { + if (!user) { + set.status = 401; + return { error: 'Unauthorized' }; + } + + const saveId = Number(id); + if (isNaN(saveId)) { + set.status = 400; + return { error: 'Invalid save ID' }; + } + + try { + const updated = await savesService.update(saveId, user.id, body); + + return { + id: updated.id, + name: updated.name, + type: updated.type, + description: updated.description, + tags: updated.tags, + visibility: updated.visibility, + shareUrl: updated.visibility === 'link' ? updated.shareUrl : undefined, + updatedAt: updated.updatedAt.toISOString(), + }; + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + set.status = 404; + return { error: 'Save not found or access denied' }; + } + set.status = 500; + return { + error: error instanceof Error ? error.message : 'Failed to update save' + }; + } + }, + { + params: t.Object({ + id: t.String(), + }), + body: t.Object({ + name: t.Optional(t.String()), + description: t.Optional(t.String()), + tags: t.Optional(t.Array(t.String())), + visibility: t.Optional(t.Union([t.Literal('public'), t.Literal('link')])), + }), + detail: { + tags: ['Saves'], + summary: 'Update save', + description: 'Updates save metadata (owner only)', + }, + auth: true + } + ) + .post( '/external', async ({ body, user, set }) => { diff --git a/apps/backend/src/services/saves.service.ts b/apps/backend/src/services/saves.service.ts index e1a0de4..5999b8b 100644 --- a/apps/backend/src/services/saves.service.ts +++ b/apps/backend/src/services/saves.service.ts @@ -8,6 +8,7 @@ import { nanoid } from 'nanoid'; import type { Visibility, CreateSaveFromUrlRequest, + UpdateSaveRequest, } from '@p1ctos4ve/shared-types'; class SavesService { @@ -161,6 +162,47 @@ class SavesService { return publicSaves; } + async update( + id: number, + userId: string, + data: UpdateSaveRequest + ): Promise { + const savedItem = await this.getById(id, userId); + + if (!savedItem || savedItem.userId !== userId) { + throw new Error('Save not found or access denied'); + } + + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (data.name !== undefined) updateData.name = data.name; + if (data.description !== undefined) updateData.description = data.description; + if (data.tags !== undefined) updateData.tags = data.tags; + if (data.visibility !== undefined) { + updateData.visibility = data.visibility; + + if (data.visibility === 'link' && !savedItem.shareUrl) { + updateData.shareUrl = this.generateShareUrl(); + } + + if (data.visibility === 'public') { + updateData.shareUrl = null; + } + } + + const [updated] = await db + .update(save) + .set(updateData) + .where(eq(save.id, id)) + .returning(); + + await this.invalidateCache(id, userId); + + return updated; + } + private hasAccess( savedItem: Save, requestUserId?: string, @@ -185,6 +227,11 @@ class SavesService { return nanoid(16); } + private async invalidateCache(saveId: number, userId: string): Promise { + await redis.del(`save:${saveId}`); + await this.invalidateUserCache(userId); + } + private async invalidateUserCache(userId: string): Promise { await redis.del(`user_saves:${userId}`); await redis.del(`public_saves:${userId}`);