mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 22:26:41 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
29
apps-archived/storage/apps/backend/src/app.module.ts
Normal file
29
apps-archived/storage/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { FileModule } from './file/file.module';
|
||||
import { FolderModule } from './folder/folder.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { TagModule } from './tag/tag.module';
|
||||
import { TrashModule } from './trash/trash.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
DatabaseModule,
|
||||
HealthModule,
|
||||
StorageModule,
|
||||
FileModule,
|
||||
FolderModule,
|
||||
ShareModule,
|
||||
TagModule,
|
||||
TrashModule,
|
||||
SearchModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
38
apps-archived/storage/apps/backend/src/db/connection.ts
Normal file
38
apps-archived/storage/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
30
apps-archived/storage/apps/backend/src/db/database.module.ts
Normal file
30
apps-archived/storage/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import type { OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection } from './connection';
|
||||
import type { Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { pgTable, uuid, primaryKey } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { files } from './files.schema';
|
||||
import { tags } from './tags.schema';
|
||||
|
||||
export const fileTags = pgTable(
|
||||
'file_tags',
|
||||
{
|
||||
fileId: uuid('file_id')
|
||||
.references(() => files.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
tagId: uuid('tag_id')
|
||||
.references(() => tags.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.fileId, table.tagId] }),
|
||||
})
|
||||
);
|
||||
|
||||
export const fileTagsRelations = relations(fileTags, ({ one }) => ({
|
||||
file: one(files, {
|
||||
fields: [fileTags.fileId],
|
||||
references: [files.id],
|
||||
}),
|
||||
tag: one(tags, {
|
||||
fields: [fileTags.tagId],
|
||||
references: [tags.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type FileTag = typeof fileTags.$inferSelect;
|
||||
export type NewFileTag = typeof fileTags.$inferInsert;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { pgTable, uuid, varchar, timestamp, bigint, integer, text } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { files } from './files.schema';
|
||||
|
||||
export const fileVersions = pgTable('file_versions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
fileId: uuid('file_id')
|
||||
.references(() => files.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
|
||||
// Version info
|
||||
versionNumber: integer('version_number').notNull(),
|
||||
|
||||
// Storage info for this version
|
||||
storagePath: varchar('storage_path', { length: 1000 }).notNull(),
|
||||
storageKey: varchar('storage_key', { length: 500 }).notNull(),
|
||||
size: bigint('size', { mode: 'number' }).notNull(),
|
||||
checksum: varchar('checksum', { length: 64 }),
|
||||
|
||||
// Metadata
|
||||
comment: text('comment'), // Optional version comment
|
||||
createdBy: varchar('created_by', { length: 255 }).notNull(),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const fileVersionsRelations = relations(fileVersions, ({ one }) => ({
|
||||
file: one(files, {
|
||||
fields: [fileVersions.fileId],
|
||||
references: [files.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type FileVersion = typeof fileVersions.$inferSelect;
|
||||
export type NewFileVersion = typeof fileVersions.$inferInsert;
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
text,
|
||||
timestamp,
|
||||
bigint,
|
||||
boolean,
|
||||
integer,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { folders } from './folders.schema';
|
||||
|
||||
export const files = pgTable('files', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
// File metadata
|
||||
name: varchar('name', { length: 500 }).notNull(),
|
||||
originalName: varchar('original_name', { length: 500 }).notNull(),
|
||||
mimeType: varchar('mime_type', { length: 255 }).notNull(),
|
||||
size: bigint('size', { mode: 'number' }).notNull(),
|
||||
|
||||
// Storage location
|
||||
storagePath: varchar('storage_path', { length: 1000 }).notNull(),
|
||||
storageKey: varchar('storage_key', { length: 500 }).notNull().unique(),
|
||||
|
||||
// Hierarchy
|
||||
parentFolderId: uuid('parent_folder_id').references(() => folders.id, { onDelete: 'set null' }),
|
||||
|
||||
// File properties
|
||||
checksum: varchar('checksum', { length: 64 }), // SHA-256
|
||||
thumbnailPath: varchar('thumbnail_path', { length: 500 }),
|
||||
|
||||
// Versioning
|
||||
currentVersion: integer('current_version').default(1).notNull(),
|
||||
|
||||
// Status flags
|
||||
isFavorite: boolean('is_favorite').default(false).notNull(),
|
||||
isDeleted: boolean('is_deleted').default(false).notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const filesRelations = relations(files, ({ one }) => ({
|
||||
parentFolder: one(folders, {
|
||||
fields: [files.parentFolderId],
|
||||
references: [folders.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type File = typeof files.$inferSelect;
|
||||
export type NewFile = typeof files.$inferInsert;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { pgTable, uuid, varchar, timestamp, boolean, text, integer } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const folders = pgTable('folders', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
// Folder metadata
|
||||
name: varchar('name', { length: 255 }).notNull(),
|
||||
color: varchar('color', { length: 20 }),
|
||||
description: text('description'),
|
||||
|
||||
// Hierarchy (self-referencing)
|
||||
parentFolderId: uuid('parent_folder_id'),
|
||||
|
||||
// Path for efficient queries (e.g., "/root-uuid/parent-uuid/current-uuid")
|
||||
path: text('path').notNull(),
|
||||
depth: integer('depth').default(0).notNull(),
|
||||
|
||||
// Status flags
|
||||
isFavorite: boolean('is_favorite').default(false).notNull(),
|
||||
isDeleted: boolean('is_deleted').default(false).notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Self-referencing relation
|
||||
export const foldersRelations = relations(folders, ({ one, many }) => ({
|
||||
parentFolder: one(folders, {
|
||||
fields: [folders.parentFolderId],
|
||||
references: [folders.id],
|
||||
relationName: 'folder_parent',
|
||||
}),
|
||||
childFolders: many(folders, { relationName: 'folder_parent' }),
|
||||
}));
|
||||
|
||||
export type Folder = typeof folders.$inferSelect;
|
||||
export type NewFolder = typeof folders.$inferInsert;
|
||||
17
apps-archived/storage/apps/backend/src/db/schema/index.ts
Normal file
17
apps-archived/storage/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Folders (must be first due to self-reference)
|
||||
export * from './folders.schema';
|
||||
|
||||
// Files (references folders)
|
||||
export * from './files.schema';
|
||||
|
||||
// File versions (references files)
|
||||
export * from './file-versions.schema';
|
||||
|
||||
// Tags
|
||||
export * from './tags.schema';
|
||||
|
||||
// File-Tags junction (references files and tags)
|
||||
export * from './file-tags.schema';
|
||||
|
||||
// Shares (references files and folders)
|
||||
export * from './shares.schema';
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { pgTable, uuid, varchar, timestamp, boolean, integer, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { files } from './files.schema';
|
||||
import { folders } from './folders.schema';
|
||||
|
||||
export const shareTypeEnum = pgEnum('share_type', ['file', 'folder']);
|
||||
export const shareAccessEnum = pgEnum('share_access', ['view', 'edit', 'download']);
|
||||
|
||||
export const shares = pgTable('shares', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(), // Owner
|
||||
|
||||
// Share target (one of these will be set)
|
||||
fileId: uuid('file_id').references(() => files.id, { onDelete: 'cascade' }),
|
||||
folderId: uuid('folder_id').references(() => folders.id, { onDelete: 'cascade' }),
|
||||
shareType: shareTypeEnum('share_type').notNull(),
|
||||
|
||||
// Share link
|
||||
shareToken: varchar('share_token', { length: 64 }).notNull().unique(),
|
||||
accessLevel: shareAccessEnum('access_level').default('view').notNull(),
|
||||
|
||||
// Security
|
||||
password: varchar('password', { length: 255 }), // Hashed password
|
||||
maxDownloads: integer('max_downloads'),
|
||||
downloadCount: integer('download_count').default(0).notNull(),
|
||||
|
||||
// Expiration
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
|
||||
// Status
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
lastAccessedAt: timestamp('last_accessed_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const sharesRelations = relations(shares, ({ one }) => ({
|
||||
file: one(files, {
|
||||
fields: [shares.fileId],
|
||||
references: [files.id],
|
||||
}),
|
||||
folder: one(folders, {
|
||||
fields: [shares.folderId],
|
||||
references: [folders.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type Share = typeof shares.$inferSelect;
|
||||
export type NewShare = typeof shares.$inferInsert;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { pgTable, uuid, varchar, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { fileTags } from './file-tags.schema';
|
||||
|
||||
export const tags = pgTable('tags', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
|
||||
name: varchar('name', { length: 50 }).notNull(),
|
||||
color: varchar('color', { length: 20 }),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const tagsRelations = relations(tags, ({ many }) => ({
|
||||
fileTags: many(fileTags),
|
||||
}));
|
||||
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type NewTag = typeof tags.$inferInsert;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateFileDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentFolderId?: string;
|
||||
}
|
||||
|
||||
export class UpdateFileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class MoveFileDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentFolderId?: string | null;
|
||||
}
|
||||
135
apps-archived/storage/apps/backend/src/file/file.controller.ts
Normal file
135
apps-archived/storage/apps/backend/src/file/file.controller.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
Res,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { FileService } from './file.service';
|
||||
import { CreateFileDto, UpdateFileDto, MoveFileDto } from './dto/create-file.dto';
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
const MAX_FILES = 10;
|
||||
|
||||
@Controller('api/v1/files')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FileController {
|
||||
constructor(private readonly fileService: FileService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('parentFolderId') parentFolderId?: string
|
||||
) {
|
||||
return this.fileService.findAll(user.userId, parentFolderId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.fileService.findOne(user.userId, id);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
})
|
||||
)
|
||||
async upload(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() dto: CreateFileDto
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
return this.fileService.upload(user.userId, file, dto);
|
||||
}
|
||||
|
||||
@Post('upload-multiple')
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', MAX_FILES, {
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
})
|
||||
)
|
||||
async uploadMultiple(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@UploadedFiles() uploadedFiles: Express.Multer.File[],
|
||||
@Body() dto: CreateFileDto
|
||||
) {
|
||||
if (!uploadedFiles || uploadedFiles.length === 0) {
|
||||
throw new BadRequestException('No files provided');
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
uploadedFiles.map((file) => this.fileService.upload(user.userId, file, dto))
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
async download(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Query('url') urlOnly: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
if (urlOnly === 'true') {
|
||||
const url = await this.fileService.getDownloadUrl(user.userId, id);
|
||||
return res.json({ url });
|
||||
}
|
||||
|
||||
const { buffer, file } = await this.fileService.download(user.userId, id);
|
||||
|
||||
res.set({
|
||||
'Content-Type': file.mimeType,
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(file.name)}"`,
|
||||
'Content-Length': buffer.length,
|
||||
});
|
||||
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateFileDto
|
||||
) {
|
||||
return this.fileService.update(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/move')
|
||||
async move(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveFileDto
|
||||
) {
|
||||
return this.fileService.move(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.fileService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/favorite')
|
||||
async toggleFavorite(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.fileService.toggleFavorite(user.userId, id);
|
||||
}
|
||||
}
|
||||
18
apps-archived/storage/apps/backend/src/file/file.module.ts
Normal file
18
apps-archived/storage/apps/backend/src/file/file.module.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { FileController } from './file.controller';
|
||||
import { FileService } from './file.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MulterModule.register({
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024, // 100MB
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [FileController],
|
||||
providers: [FileService],
|
||||
exports: [FileService],
|
||||
})
|
||||
export class FileModule {}
|
||||
166
apps-archived/storage/apps/backend/src/file/file.service.ts
Normal file
166
apps-archived/storage/apps/backend/src/file/file.service.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { files, fileVersions } from '../db/schema';
|
||||
import type { File, NewFile, NewFileVersion } from '../db/schema';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { CreateFileDto, UpdateFileDto, MoveFileDto } from './dto/create-file.dto';
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private storageService: StorageService
|
||||
) {}
|
||||
|
||||
async findAll(userId: string, parentFolderId?: string): Promise<File[]> {
|
||||
if (parentFolderId) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(
|
||||
and(
|
||||
eq(files.userId, userId),
|
||||
eq(files.parentFolderId, parentFolderId),
|
||||
eq(files.isDeleted, false)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Root files (no parent folder)
|
||||
return this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(
|
||||
and(eq(files.userId, userId), isNull(files.parentFolderId), eq(files.isDeleted, false))
|
||||
);
|
||||
}
|
||||
|
||||
async findOne(userId: string, id: string): Promise<File> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, false)));
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async upload(userId: string, file: Express.Multer.File, dto: CreateFileDto): Promise<File> {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
|
||||
// Upload to S3
|
||||
const uploadResult = await this.storageService.uploadFile(
|
||||
userId,
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
file.mimetype
|
||||
);
|
||||
|
||||
// Create file record
|
||||
const newFile: NewFile = {
|
||||
userId,
|
||||
name: file.originalname,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
storagePath: uploadResult.storagePath,
|
||||
storageKey: uploadResult.storageKey,
|
||||
parentFolderId: dto.parentFolderId || null,
|
||||
currentVersion: 1,
|
||||
};
|
||||
|
||||
const result = await this.db.insert(files).values(newFile).returning();
|
||||
const createdFile = result[0];
|
||||
|
||||
// Create initial version record
|
||||
const version: NewFileVersion = {
|
||||
fileId: createdFile.id,
|
||||
versionNumber: 1,
|
||||
storagePath: uploadResult.storagePath,
|
||||
storageKey: uploadResult.storageKey,
|
||||
size: file.size,
|
||||
createdBy: userId,
|
||||
};
|
||||
|
||||
await this.db.insert(fileVersions).values(version);
|
||||
|
||||
return createdFile;
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, dto: UpdateFileDto): Promise<File> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
const result = await this.db
|
||||
.update(files)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async move(userId: string, id: string, dto: MoveFileDto): Promise<File> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
const result = await this.db
|
||||
.update(files)
|
||||
.set({
|
||||
parentFolderId: dto.parentFolderId || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
// Soft delete
|
||||
await this.db
|
||||
.update(files)
|
||||
.set({
|
||||
isDeleted: true,
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId)));
|
||||
}
|
||||
|
||||
async toggleFavorite(userId: string, id: string): Promise<File> {
|
||||
const file = await this.findOne(userId, id);
|
||||
|
||||
const result = await this.db
|
||||
.update(files)
|
||||
.set({
|
||||
isFavorite: !file.isFavorite,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async download(userId: string, id: string): Promise<{ buffer: Buffer; file: File }> {
|
||||
const file = await this.findOne(userId, id);
|
||||
const buffer = await this.storageService.downloadFile(file.storageKey);
|
||||
return { buffer, file };
|
||||
}
|
||||
|
||||
async getDownloadUrl(userId: string, id: string): Promise<string> {
|
||||
const file = await this.findOne(userId, id);
|
||||
return this.storageService.getDownloadUrl(file.storageKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateFolderDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentFolderId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateFolderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class MoveFolderDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentFolderId?: string | null;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { FolderService } from './folder.service';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { UpdateFolderDto, MoveFolderDto } from './dto/update-folder.dto';
|
||||
|
||||
@Controller('api/v1/folders')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FolderController {
|
||||
constructor(private readonly folderService: FolderService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('parentFolderId') parentFolderId?: string
|
||||
) {
|
||||
return this.folderService.findAll(user.userId, parentFolderId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.folderService.findOne(user.userId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFolderDto) {
|
||||
return this.folderService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateFolderDto
|
||||
) {
|
||||
return this.folderService.update(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/move')
|
||||
async move(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveFolderDto
|
||||
) {
|
||||
return this.folderService.move(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.folderService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/favorite')
|
||||
async toggleFavorite(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.folderService.toggleFavorite(user.userId, id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { FolderController } from './folder.controller';
|
||||
import { FolderService } from './folder.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FolderController],
|
||||
providers: [FolderService],
|
||||
exports: [FolderService],
|
||||
})
|
||||
export class FolderModule {}
|
||||
147
apps-archived/storage/apps/backend/src/folder/folder.service.ts
Normal file
147
apps-archived/storage/apps/backend/src/folder/folder.service.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { folders } from '../db/schema';
|
||||
import type { Folder, NewFolder } from '../db/schema';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { UpdateFolderDto, MoveFolderDto } from './dto/update-folder.dto';
|
||||
|
||||
@Injectable()
|
||||
export class FolderService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string, parentFolderId?: string): Promise<Folder[]> {
|
||||
if (parentFolderId) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.userId, userId),
|
||||
eq(folders.parentFolderId, parentFolderId),
|
||||
eq(folders.isDeleted, false)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Root folders (no parent)
|
||||
return this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.userId, userId),
|
||||
isNull(folders.parentFolderId),
|
||||
eq(folders.isDeleted, false)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async findOne(userId: string, id: string): Promise<Folder> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId), eq(folders.isDeleted, false)));
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Folder not found');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async create(userId: string, dto: CreateFolderDto): Promise<Folder> {
|
||||
let path = `/${dto.name}`;
|
||||
let depth = 0;
|
||||
|
||||
if (dto.parentFolderId) {
|
||||
const parent = await this.findOne(userId, dto.parentFolderId);
|
||||
path = `${parent.path}/${dto.name}`;
|
||||
depth = parent.depth + 1;
|
||||
}
|
||||
|
||||
const newFolder: NewFolder = {
|
||||
userId,
|
||||
name: dto.name,
|
||||
parentFolderId: dto.parentFolderId || null,
|
||||
color: dto.color,
|
||||
description: dto.description,
|
||||
path,
|
||||
depth,
|
||||
};
|
||||
|
||||
const result = await this.db.insert(folders).values(newFolder).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, dto: UpdateFolderDto): Promise<Folder> {
|
||||
const folder = await this.findOne(userId, id);
|
||||
|
||||
const result = await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async move(userId: string, id: string, dto: MoveFolderDto): Promise<Folder> {
|
||||
const folder = await this.findOne(userId, id);
|
||||
|
||||
let newPath = `/${folder.name}`;
|
||||
let newDepth = 0;
|
||||
|
||||
if (dto.parentFolderId) {
|
||||
const parent = await this.findOne(userId, dto.parentFolderId);
|
||||
newPath = `${parent.path}/${folder.name}`;
|
||||
newDepth = parent.depth + 1;
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
parentFolderId: dto.parentFolderId || null,
|
||||
path: newPath,
|
||||
depth: newDepth,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
// Soft delete
|
||||
await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
isDeleted: true,
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)));
|
||||
}
|
||||
|
||||
async toggleFavorite(userId: string, id: string): Promise<Folder> {
|
||||
const folder = await this.findOne(userId, id);
|
||||
|
||||
const result = await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
isFavorite: !folder.isFavorite,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('api/v1/health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'storage-backend',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
32
apps-archived/storage/apps/backend/src/main.ts
Normal file
32
apps-archived/storage/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('PORT') || 3016;
|
||||
const corsOrigins = configService.get<string>('CORS_ORIGINS') || '';
|
||||
|
||||
// Enable CORS
|
||||
app.enableCors({
|
||||
origin: corsOrigins.split(',').filter(Boolean),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
await app.listen(port);
|
||||
console.log(`Storage backend running on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Controller('api/v1')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SearchController {
|
||||
constructor(private readonly searchService: SearchService) {}
|
||||
|
||||
@Get('search')
|
||||
async search(@CurrentUser() user: CurrentUserData, @Query('q') query: string) {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return { files: [], folders: [] };
|
||||
}
|
||||
return this.searchService.search(user.userId, query.trim());
|
||||
}
|
||||
|
||||
@Get('favorites')
|
||||
async getFavorites(@CurrentUser() user: CurrentUserData) {
|
||||
return this.searchService.getFavorites(user.userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
exports: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq, and, ilike, or } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { files, folders } from '../db/schema';
|
||||
import type { File, Folder } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async search(userId: string, query: string): Promise<{ files: File[]; folders: Folder[] }> {
|
||||
const searchPattern = `%${query}%`;
|
||||
|
||||
const matchingFiles = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(
|
||||
and(
|
||||
eq(files.userId, userId),
|
||||
eq(files.isDeleted, false),
|
||||
or(ilike(files.name, searchPattern), ilike(files.originalName, searchPattern))
|
||||
)
|
||||
)
|
||||
.limit(50);
|
||||
|
||||
const matchingFolders = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(
|
||||
and(
|
||||
eq(folders.userId, userId),
|
||||
eq(folders.isDeleted, false),
|
||||
or(ilike(folders.name, searchPattern), ilike(folders.description, searchPattern))
|
||||
)
|
||||
)
|
||||
.limit(50);
|
||||
|
||||
return { files: matchingFiles, folders: matchingFolders };
|
||||
}
|
||||
|
||||
async getFavorites(userId: string): Promise<{ files: File[]; folders: Folder[] }> {
|
||||
const favoriteFiles = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.userId, userId), eq(files.isDeleted, false), eq(files.isFavorite, true)));
|
||||
|
||||
const favoriteFolders = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(
|
||||
and(eq(folders.userId, userId), eq(folders.isDeleted, false), eq(folders.isFavorite, true))
|
||||
);
|
||||
|
||||
return { files: favoriteFiles, folders: favoriteFolders };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Controller, Get, Post, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ShareService } from './share.service';
|
||||
|
||||
@Controller('api/v1/shares')
|
||||
export class ShareController {
|
||||
constructor(private readonly shareService: ShareService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.shareService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Get(':token')
|
||||
async findByToken(@Param('token') token: string) {
|
||||
return this.shareService.findByToken(token);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body()
|
||||
dto: {
|
||||
fileId?: string;
|
||||
folderId?: string;
|
||||
accessLevel?: 'view' | 'edit' | 'download';
|
||||
password?: string;
|
||||
maxDownloads?: number;
|
||||
expiresInDays?: number;
|
||||
}
|
||||
) {
|
||||
const expiresAt = dto.expiresInDays
|
||||
? new Date(Date.now() + dto.expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: undefined;
|
||||
|
||||
return this.shareService.create(user.userId, {
|
||||
fileId: dto.fileId,
|
||||
folderId: dto.folderId,
|
||||
accessLevel: dto.accessLevel,
|
||||
password: dto.password,
|
||||
maxDownloads: dto.maxDownloads,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.shareService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps-archived/storage/apps/backend/src/share/share.module.ts
Normal file
10
apps-archived/storage/apps/backend/src/share/share.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ShareController } from './share.controller';
|
||||
import { ShareService } from './share.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ShareController],
|
||||
providers: [ShareService],
|
||||
exports: [ShareService],
|
||||
})
|
||||
export class ShareModule {}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { shares } from '../db/schema';
|
||||
import type { Share, NewShare } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class ShareService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Share[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(and(eq(shares.userId, userId), eq(shares.isActive, true)));
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<Share> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(shares)
|
||||
.where(and(eq(shares.shareToken, token), eq(shares.isActive, true)));
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
const share = result[0];
|
||||
|
||||
// Check expiration
|
||||
if (share.expiresAt && new Date() > share.expiresAt) {
|
||||
throw new NotFoundException('Share link has expired');
|
||||
}
|
||||
|
||||
// Check download limit
|
||||
if (share.maxDownloads && share.downloadCount >= share.maxDownloads) {
|
||||
throw new NotFoundException('Share link download limit reached');
|
||||
}
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
data: {
|
||||
fileId?: string;
|
||||
folderId?: string;
|
||||
accessLevel?: 'view' | 'edit' | 'download';
|
||||
password?: string;
|
||||
maxDownloads?: number;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
): Promise<Share> {
|
||||
const shareToken = randomBytes(32).toString('hex');
|
||||
const shareType = data.fileId ? 'file' : 'folder';
|
||||
|
||||
const newShare: NewShare = {
|
||||
userId,
|
||||
fileId: data.fileId,
|
||||
folderId: data.folderId,
|
||||
shareType,
|
||||
shareToken,
|
||||
accessLevel: data.accessLevel || 'view',
|
||||
password: data.password, // Should be hashed in production
|
||||
maxDownloads: data.maxDownloads,
|
||||
expiresAt: data.expiresAt,
|
||||
};
|
||||
|
||||
const result = await this.db.insert(shares).values(newShare).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(shares)
|
||||
.set({ isActive: false })
|
||||
.where(and(eq(shares.id, id), eq(shares.userId, userId)));
|
||||
}
|
||||
|
||||
async incrementDownloadCount(id: string): Promise<void> {
|
||||
const share = await this.db.select().from(shares).where(eq(shares.id, id));
|
||||
if (share.length > 0) {
|
||||
await this.db
|
||||
.update(shares)
|
||||
.set({
|
||||
downloadCount: share[0].downloadCount + 1,
|
||||
lastAccessedAt: new Date(),
|
||||
})
|
||||
.where(eq(shares.id, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
createStorageStorage,
|
||||
StorageClient,
|
||||
generateUserFileKey,
|
||||
getContentType,
|
||||
validateFileSize,
|
||||
} from '@manacore/shared-storage';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private storage: StorageClient;
|
||||
private maxFileSize: number;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const publicUrl = this.configService.get<string>('STORAGE_S3_PUBLIC_URL');
|
||||
this.storage = createStorageStorage(publicUrl);
|
||||
this.maxFileSize = this.configService.get<number>('STORAGE_MAX_FILE_SIZE') || 100 * 1024 * 1024; // 100MB default
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
userId: string,
|
||||
buffer: Buffer,
|
||||
originalName: string,
|
||||
mimeType: string,
|
||||
subfolder?: string
|
||||
) {
|
||||
if (!validateFileSize(buffer.length, this.maxFileSize / (1024 * 1024))) {
|
||||
throw new Error(
|
||||
`File size exceeds maximum allowed size of ${this.maxFileSize / (1024 * 1024)}MB`
|
||||
);
|
||||
}
|
||||
|
||||
const storageKey = generateUserFileKey(userId, originalName, subfolder);
|
||||
|
||||
const result = await this.storage.upload(storageKey, buffer, {
|
||||
contentType: mimeType || getContentType(originalName),
|
||||
});
|
||||
|
||||
return {
|
||||
storageKey,
|
||||
storagePath: result.key,
|
||||
publicUrl: result.url,
|
||||
etag: result.etag,
|
||||
};
|
||||
}
|
||||
|
||||
async downloadFile(storageKey: string): Promise<Buffer> {
|
||||
return this.storage.download(storageKey);
|
||||
}
|
||||
|
||||
async deleteFile(storageKey: string): Promise<void> {
|
||||
await this.storage.delete(storageKey);
|
||||
}
|
||||
|
||||
async fileExists(storageKey: string): Promise<boolean> {
|
||||
return this.storage.exists(storageKey);
|
||||
}
|
||||
|
||||
async getDownloadUrl(storageKey: string, expiresIn = 3600): Promise<string> {
|
||||
return this.storage.getDownloadUrl(storageKey, { expiresIn });
|
||||
}
|
||||
|
||||
async getUploadUrl(storageKey: string, expiresIn = 3600): Promise<string> {
|
||||
return this.storage.getUploadUrl(storageKey, { expiresIn });
|
||||
}
|
||||
|
||||
getPublicUrl(storageKey: string): string | undefined {
|
||||
return this.storage.getPublicUrl(storageKey);
|
||||
}
|
||||
}
|
||||
38
apps-archived/storage/apps/backend/src/tag/tag.controller.ts
Normal file
38
apps-archived/storage/apps/backend/src/tag/tag.controller.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { TagService } from './tag.service';
|
||||
|
||||
@Controller('api/v1/tags')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TagController {
|
||||
constructor(private readonly tagService: TagService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.tagService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: { name: string; color?: string }
|
||||
) {
|
||||
return this.tagService.create(user.userId, dto.name, dto.color);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { name?: string; color?: string }
|
||||
) {
|
||||
return this.tagService.update(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.tagService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps-archived/storage/apps/backend/src/tag/tag.module.ts
Normal file
10
apps-archived/storage/apps/backend/src/tag/tag.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TagController } from './tag.controller';
|
||||
import { TagService } from './tag.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TagController],
|
||||
providers: [TagService],
|
||||
exports: [TagService],
|
||||
})
|
||||
export class TagModule {}
|
||||
64
apps-archived/storage/apps/backend/src/tag/tag.service.ts
Normal file
64
apps-archived/storage/apps/backend/src/tag/tag.service.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { tags, fileTags } from '../db/schema';
|
||||
import type { Tag, NewTag } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class TagService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
||||
|
||||
async findAll(userId: string): Promise<Tag[]> {
|
||||
return this.db.select().from(tags).where(eq(tags.userId, userId));
|
||||
}
|
||||
|
||||
async create(userId: string, name: string, color?: string): Promise<Tag> {
|
||||
const newTag: NewTag = {
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
};
|
||||
|
||||
const result = await this.db.insert(tags).values(newTag).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, data: { name?: string; color?: string }): Promise<Tag> {
|
||||
const result = await this.db
|
||||
.update(tags)
|
||||
.set(data)
|
||||
.where(and(eq(tags.id, id), eq(tags.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Tag not found');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.db.delete(tags).where(and(eq(tags.id, id), eq(tags.userId, userId)));
|
||||
}
|
||||
|
||||
async addTagToFile(fileId: string, tagId: string): Promise<void> {
|
||||
await this.db.insert(fileTags).values({ fileId, tagId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeTagFromFile(fileId: string, tagId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(fileTags)
|
||||
.where(and(eq(fileTags.fileId, fileId), eq(fileTags.tagId, tagId)));
|
||||
}
|
||||
|
||||
async getFileTags(fileId: string): Promise<Tag[]> {
|
||||
const result = await this.db
|
||||
.select({ tag: tags })
|
||||
.from(fileTags)
|
||||
.innerJoin(tags, eq(fileTags.tagId, tags.id))
|
||||
.where(eq(fileTags.fileId, fileId));
|
||||
|
||||
return result.map((r) => r.tag);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { Controller, Get, Post, Delete, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { TrashService } from './trash.service';
|
||||
|
||||
@Controller('api/v1/trash')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TrashController {
|
||||
constructor(private readonly trashService: TrashService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.trashService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
async restore(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Query('type') type: 'file' | 'folder'
|
||||
) {
|
||||
if (type === 'folder') {
|
||||
return this.trashService.restoreFolder(user.userId, id);
|
||||
}
|
||||
return this.trashService.restoreFile(user.userId, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async permanentlyDelete(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Query('type') type: 'file' | 'folder'
|
||||
) {
|
||||
if (type === 'folder') {
|
||||
await this.trashService.permanentlyDeleteFolder(user.userId, id);
|
||||
} else {
|
||||
await this.trashService.permanentlyDeleteFile(user.userId, id);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Delete()
|
||||
async emptyTrash(@CurrentUser() user: CurrentUserData) {
|
||||
await this.trashService.emptyTrash(user.userId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
10
apps-archived/storage/apps/backend/src/trash/trash.module.ts
Normal file
10
apps-archived/storage/apps/backend/src/trash/trash.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TrashController } from './trash.controller';
|
||||
import { TrashService } from './trash.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TrashController],
|
||||
providers: [TrashService],
|
||||
exports: [TrashService],
|
||||
})
|
||||
export class TrashModule {}
|
||||
107
apps-archived/storage/apps/backend/src/trash/trash.service.ts
Normal file
107
apps-archived/storage/apps/backend/src/trash/trash.service.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { Database } from '../db/connection';
|
||||
import { files, folders } from '../db/schema';
|
||||
import type { File, Folder } from '../db/schema';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class TrashService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||
private storageService: StorageService
|
||||
) {}
|
||||
|
||||
async findAll(userId: string): Promise<{ files: File[]; folders: Folder[] }> {
|
||||
const trashedFiles = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.userId, userId), eq(files.isDeleted, true)));
|
||||
|
||||
const trashedFolders = await this.db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.userId, userId), eq(folders.isDeleted, true)));
|
||||
|
||||
return { files: trashedFiles, folders: trashedFolders };
|
||||
}
|
||||
|
||||
async restoreFile(userId: string, id: string): Promise<File> {
|
||||
const result = await this.db
|
||||
.update(files)
|
||||
.set({
|
||||
isDeleted: false,
|
||||
deletedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('File not found in trash');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async restoreFolder(userId: string, id: string): Promise<Folder> {
|
||||
const result = await this.db
|
||||
.update(folders)
|
||||
.set({
|
||||
isDeleted: false,
|
||||
deletedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Folder not found in trash');
|
||||
}
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async permanentlyDeleteFile(userId: string, id: string): Promise<void> {
|
||||
const file = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.id, id), eq(files.userId, userId), eq(files.isDeleted, true)));
|
||||
|
||||
if (file.length === 0) {
|
||||
throw new NotFoundException('File not found in trash');
|
||||
}
|
||||
|
||||
// Delete from S3
|
||||
await this.storageService.deleteFile(file[0].storageKey);
|
||||
|
||||
// Delete from database
|
||||
await this.db.delete(files).where(eq(files.id, id));
|
||||
}
|
||||
|
||||
async permanentlyDeleteFolder(userId: string, id: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(folders)
|
||||
.where(and(eq(folders.id, id), eq(folders.userId, userId), eq(folders.isDeleted, true)));
|
||||
}
|
||||
|
||||
async emptyTrash(userId: string): Promise<void> {
|
||||
// Get all trashed files to delete from S3
|
||||
const trashedFiles = await this.db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.userId, userId), eq(files.isDeleted, true)));
|
||||
|
||||
// Delete from S3
|
||||
for (const file of trashedFiles) {
|
||||
await this.storageService.deleteFile(file.storageKey);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await this.db.delete(files).where(and(eq(files.userId, userId), eq(files.isDeleted, true)));
|
||||
await this.db
|
||||
.delete(folders)
|
||||
.where(and(eq(folders.userId, userId), eq(folders.isDeleted, true)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue