Merge pull request 'feat/saves-service Добавлен CRUD для сохранений' (#4) from feat/saves-service into lord
Reviewed-on: #4
This commit is contained in:
@ -173,4 +173,210 @@ export const savesController = new Elysia({ prefix: '/saves' })
|
|||||||
description: 'Redirects to a presigned URL for downloading the file',
|
description: 'Redirects to a presigned URL for downloading the file',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.delete(
|
||||||
|
'/:id',
|
||||||
|
async ({ params: { id }, 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 {
|
||||||
|
await savesService.delete(saveId, user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Сейв успешно удален',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('not found')) {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: 'Save not found' };
|
||||||
|
}
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete save'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
detail: {
|
||||||
|
tags: ['Saves'],
|
||||||
|
summary: 'Delete save',
|
||||||
|
description: 'Deletes a save by ID (owner only)',
|
||||||
|
},
|
||||||
|
auth: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.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 }) => {
|
||||||
|
if (!user) {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const save = await savesService.createFromUrl(user.id, body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: save.id,
|
||||||
|
name: save.name,
|
||||||
|
type: save.type,
|
||||||
|
url: save.url,
|
||||||
|
visibility: save.visibility,
|
||||||
|
shareUrl: save.visibility === 'link' ? save.shareUrl : undefined,
|
||||||
|
createdAt: save.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating save from URL:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create save from URL';
|
||||||
|
|
||||||
|
if (errorMessage.includes('Unsupported') || errorMessage.includes('not found')) {
|
||||||
|
set.status = 400;
|
||||||
|
} else if (errorMessage.includes('Failed to download') || errorMessage.includes('Failed to scrape')) {
|
||||||
|
set.status = 502;
|
||||||
|
} else {
|
||||||
|
set.status = 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: errorMessage };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
url: t.String({ format: 'uri' }),
|
||||||
|
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: 'Create save from external URL',
|
||||||
|
description: 'Downloads and saves a media file from external source',
|
||||||
|
},
|
||||||
|
auth: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.post(
|
||||||
|
'/upload',
|
||||||
|
async ({ body, user, set }) => {
|
||||||
|
if (!user) {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { file, name, description, tags, visibility } = body;
|
||||||
|
|
||||||
|
const save = await savesService.createFromFile(user.id, file, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tags,
|
||||||
|
visibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: save.id,
|
||||||
|
name: save.name,
|
||||||
|
type: save.type,
|
||||||
|
url: save.url,
|
||||||
|
visibility: save.visibility,
|
||||||
|
shareUrl: save.visibility === 'link' ? save.shareUrl : undefined,
|
||||||
|
createdAt: save.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
set.status = 500;
|
||||||
|
return {
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to upload file'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
file: t.File(),
|
||||||
|
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')])),
|
||||||
|
}),
|
||||||
|
type: 'multipart/form-data',
|
||||||
|
detail: {
|
||||||
|
tags: ['Saves'],
|
||||||
|
summary: 'Upload file',
|
||||||
|
description: 'Uploads a media file from device',
|
||||||
|
},
|
||||||
|
auth: true
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,10 +1,103 @@
|
|||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { db, save, type Save } from '@/db';
|
import { db, save, type Save, type NewSave } from '@/db';
|
||||||
|
import { s3Service } from './s3.service';
|
||||||
|
import { scraperService } from './scraper.service';
|
||||||
import { redis } from './redis.service';
|
import { redis } from './redis.service';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import type {
|
||||||
|
Visibility,
|
||||||
|
CreateSaveFromUrlRequest,
|
||||||
|
UpdateSaveRequest,
|
||||||
|
} from '@p1ctos4ve/shared-types';
|
||||||
|
|
||||||
class SavesService {
|
class SavesService {
|
||||||
private readonly CACHE_TTL = 3600;
|
private readonly CACHE_TTL = 3600;
|
||||||
|
|
||||||
|
async createFromFile(
|
||||||
|
userId: string,
|
||||||
|
file: File,
|
||||||
|
metadata: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
visibility?: Visibility;
|
||||||
|
}
|
||||||
|
): Promise<Save> {
|
||||||
|
const mimeType = file.type;
|
||||||
|
const fileType = s3Service.getFileType(mimeType);
|
||||||
|
|
||||||
|
if (fileType === 'unknown') {
|
||||||
|
throw new Error('Unsupported file type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key, url, size } = await s3Service.uploadFile(file, userId, mimeType);
|
||||||
|
|
||||||
|
const shareUrl =
|
||||||
|
metadata.visibility === 'link' ? this.generateShareUrl() : undefined;
|
||||||
|
|
||||||
|
const newSave: NewSave = {
|
||||||
|
userId,
|
||||||
|
name: metadata.name || file.name || 'Untitled',
|
||||||
|
description: metadata.description || '',
|
||||||
|
type: fileType,
|
||||||
|
tags: metadata.tags || [],
|
||||||
|
visibility: metadata.visibility || 'link',
|
||||||
|
shareUrl,
|
||||||
|
s3Key: key,
|
||||||
|
url,
|
||||||
|
fileSize: size,
|
||||||
|
mimeType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [savedItem] = await db.insert(save).values(newSave).returning();
|
||||||
|
|
||||||
|
await this.invalidateUserCache(userId);
|
||||||
|
|
||||||
|
return savedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFromUrl(
|
||||||
|
userId: string,
|
||||||
|
data: CreateSaveFromUrlRequest
|
||||||
|
): Promise<Save> {
|
||||||
|
const scrapedMedia = await scraperService.scrapeUrl(data.url);
|
||||||
|
|
||||||
|
const { buffer, mimeType, size } = await s3Service.downloadFromUrl(
|
||||||
|
scrapedMedia.url
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileType = s3Service.getFileType(mimeType);
|
||||||
|
|
||||||
|
if (fileType === 'unknown') {
|
||||||
|
throw new Error('Unsupported file type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key, url } = await s3Service.uploadFile(buffer, userId, mimeType);
|
||||||
|
|
||||||
|
const shareUrl =
|
||||||
|
data.visibility === 'link' ? this.generateShareUrl() : undefined;
|
||||||
|
|
||||||
|
const newSave: NewSave = {
|
||||||
|
userId,
|
||||||
|
name: data.name || scrapedMedia.title || 'Untitled',
|
||||||
|
description: data.description || scrapedMedia.description || '',
|
||||||
|
type: fileType,
|
||||||
|
tags: data.tags || [],
|
||||||
|
visibility: data.visibility || 'link',
|
||||||
|
shareUrl,
|
||||||
|
s3Key: key,
|
||||||
|
url,
|
||||||
|
fileSize: size,
|
||||||
|
mimeType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [savedItem] = await db.insert(save).values(newSave).returning();
|
||||||
|
|
||||||
|
await this.invalidateUserCache(userId);
|
||||||
|
|
||||||
|
return savedItem;
|
||||||
|
}
|
||||||
|
|
||||||
async getById(
|
async getById(
|
||||||
id: number,
|
id: number,
|
||||||
requestUserId?: string,
|
requestUserId?: string,
|
||||||
@ -68,6 +161,61 @@ class SavesService {
|
|||||||
return publicSaves;
|
return publicSaves;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: number,
|
||||||
|
userId: string,
|
||||||
|
data: UpdateSaveRequest
|
||||||
|
): Promise<Save> {
|
||||||
|
const savedItem = await this.getById(id, userId);
|
||||||
|
|
||||||
|
if (!savedItem || savedItem.userId !== userId) {
|
||||||
|
throw new Error('Save not found or access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Partial<NewSave> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: number, userId: string): Promise<void> {
|
||||||
|
const savedItem = await this.getById(id, userId);
|
||||||
|
|
||||||
|
if (!savedItem || savedItem.userId !== userId) {
|
||||||
|
throw new Error('Save not found or access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
await s3Service.deleteFile(savedItem.s3Key);
|
||||||
|
|
||||||
|
await db.delete(save).where(eq(save.id, id));
|
||||||
|
|
||||||
|
await this.invalidateCache(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
private hasAccess(
|
private hasAccess(
|
||||||
savedItem: Save,
|
savedItem: Save,
|
||||||
requestUserId?: string,
|
requestUserId?: string,
|
||||||
@ -87,6 +235,20 @@ class SavesService {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private generateShareUrl(): string {
|
||||||
|
return nanoid(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async invalidateCache(saveId: number, userId: string): Promise<void> {
|
||||||
|
await redis.del(`save:${saveId}`);
|
||||||
|
await this.invalidateUserCache(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async invalidateUserCache(userId: string): Promise<void> {
|
||||||
|
await redis.del(`user_saves:${userId}`);
|
||||||
|
await redis.del(`public_saves:${userId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const savesService = new SavesService();
|
export const savesService = new SavesService();
|
||||||
|
|||||||
Reference in New Issue
Block a user