Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 deletions

View 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 {}

View 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>;

View 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();
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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';

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View 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);
}
}

View 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 {}

View 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);
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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 {}

View 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];
}
}

View file

@ -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(),
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View 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();

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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 };
}
}

View file

@ -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 };
}
}

View 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 {}

View file

@ -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));
}
}
}

View file

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { StorageService } from './storage.service';
@Global()
@Module({
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -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);
}
}

View 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 };
}
}

View 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 {}

View 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);
}
}

View file

@ -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 };
}
}

View 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 {}

View 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)));
}
}