init: scaffold complet Sakuin — backend NestJS + frontend SvelteKit + CI/CD + deploy VPS
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 29s

Backend: 5 modules (auth, user, work, list, health), AniList GraphQL proxy,
SuperOAuth PKCE introspection, XP system, migrations TypeORM.
Frontend: SvelteKit adapter-node, PWA manifest, dark theme, pages home/search/list/profile/callback.
Infra: CI/CD Gitea vps-runner, Apache vhost SSL, pm2 sakuin-backend + sakuin-frontend, port 4002.
License: BSL 1.1 (Apache 2.0 en 2028).
This commit is contained in:
2026-03-25 01:43:32 +01:00
commit f1cff74d83
56 changed files with 9891 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
Req,
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ListService } from './list.service';
import { ListStatus } from './user-work.entity';
import { AuthGuard } from '../auth/auth.guard';
import { UserService } from '../user/user.service';
@Controller('api/list')
@UseGuards(AuthGuard)
export class ListController {
constructor(
private readonly listService: ListService,
private readonly userService: UserService,
) {}
@Get()
async getList(@Req() req: any, @Query('status') status?: ListStatus) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.getUserList(user.id, status);
}
@Post()
async addToList(
@Req() req: any,
@Body() body: { anilistId: number; status: ListStatus },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.addToList(user.id, body.anilistId, body.status);
}
@Put(':id/progress')
async updateProgress(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: { progress: number },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.updateProgress(user.id, id, body.progress);
}
@Put(':id/status')
async updateStatus(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: { status: ListStatus },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.updateStatus(user.id, id, body.status);
}
@Put(':id/score')
async setScore(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
@Body() body: { score: number },
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
return this.listService.setScore(user.id, id, body.score);
}
@Delete(':id')
async remove(
@Req() req: any,
@Param('id', ParseIntPipe) id: number,
) {
const user = await this.userService.findOrCreate({
id: req.user.id,
nickname: req.user.nickname,
avatar: req.user.avatar,
});
await this.listService.removeFromList(user.id, id);
return { deleted: true };
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserWork } from './user-work.entity';
import { ListController } from './list.controller';
import { ListService } from './list.service';
import { WorkModule } from '../work/work.module';
import { UserModule } from '../user/user.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([UserWork]),
WorkModule,
UserModule,
AuthModule,
],
controllers: [ListController],
providers: [ListService],
})
export class ListModule {}

View File

@@ -0,0 +1,137 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserWork, ListStatus } from './user-work.entity';
import { WorkService } from '../work/work.service';
import { UserService } from '../user/user.service';
const XP_ADD_WORK = 10;
const XP_PROGRESS = 5;
const XP_COMPLETE = 100;
const XP_SCORE = 15;
@Injectable()
export class ListService {
constructor(
@InjectRepository(UserWork)
private readonly userWorkRepo: Repository<UserWork>,
private readonly workService: WorkService,
private readonly userService: UserService,
) {}
async getUserList(userId: number, status?: ListStatus) {
const where: any = { userId };
if (status) where.status = status;
return this.userWorkRepo.find({
where,
relations: ['work'],
order: { updatedAt: 'DESC' },
});
}
async addToList(
userId: number,
anilistId: number,
status: ListStatus,
): Promise<UserWork> {
const work = await this.workService.findOrCreateFromAniList(anilistId);
const existing = await this.userWorkRepo.findOne({
where: { userId, workId: work.id },
});
if (existing) {
existing.status = status;
return this.userWorkRepo.save(existing);
}
const userWork = this.userWorkRepo.create({
userId,
workId: work.id,
status,
progress: 0,
});
const saved = await this.userWorkRepo.save(userWork);
await this.userService.addXp(userId, XP_ADD_WORK);
return saved;
}
async updateProgress(
userId: number,
userWorkId: number,
progress: number,
): Promise<UserWork> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
relations: ['work'],
});
if (!uw) throw new NotFoundException('Entry not found');
const oldProgress = uw.progress;
uw.progress = progress;
const total = uw.work.totalEpisodes || uw.work.totalChapters;
if (total && progress >= total && uw.status !== ListStatus.COMPLETED) {
uw.status = ListStatus.COMPLETED;
uw.completedAt = new Date();
await this.userService.addXp(userId, XP_COMPLETE);
}
const saved = await this.userWorkRepo.save(uw);
if (progress > oldProgress) {
const delta = progress - oldProgress;
await this.userService.addXp(userId, delta * XP_PROGRESS);
}
return saved;
}
async updateStatus(
userId: number,
userWorkId: number,
status: ListStatus,
): Promise<UserWork> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
});
if (!uw) throw new NotFoundException('Entry not found');
uw.status = status;
if (status === ListStatus.COMPLETED) {
uw.completedAt = new Date();
await this.userService.addXp(userId, XP_COMPLETE);
}
return this.userWorkRepo.save(uw);
}
async setScore(
userId: number,
userWorkId: number,
score: number,
): Promise<UserWork> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
});
if (!uw) throw new NotFoundException('Entry not found');
const hadScore = uw.score !== null;
uw.score = score;
const saved = await this.userWorkRepo.save(uw);
if (!hadScore) {
await this.userService.addXp(userId, XP_SCORE);
}
return saved;
}
async removeFromList(userId: number, userWorkId: number): Promise<void> {
const uw = await this.userWorkRepo.findOne({
where: { id: userWorkId, userId },
});
if (!uw) throw new NotFoundException('Entry not found');
await this.userWorkRepo.remove(uw);
}
}

View File

@@ -0,0 +1,60 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
Unique,
} from 'typeorm';
import { User } from '../user/user.entity';
import { Work } from '../work/work.entity';
export enum ListStatus {
WATCHING = 'watching',
READING = 'reading',
COMPLETED = 'completed',
DROPPED = 'dropped',
PAUSED = 'paused',
PLAN_TO = 'plan_to',
}
@Entity('user_works')
@Unique(['user', 'work'])
export class UserWork {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => User, (u) => u.userWorks, { onDelete: 'CASCADE' })
user: User;
@Column()
userId: number;
@ManyToOne(() => Work, (w) => w.userWorks, { onDelete: 'CASCADE' })
work: Work;
@Column()
workId: number;
@Column({ type: 'enum', enum: ListStatus })
status: ListStatus;
@Column({ default: 0 })
progress: number;
@Column({ type: 'decimal', precision: 3, scale: 1, nullable: true })
score: number;
@Column({ type: 'datetime', nullable: true })
startedAt: Date;
@Column({ type: 'datetime', nullable: true })
completedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}