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
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:
105
backend/src/list/list.controller.ts
Normal file
105
backend/src/list/list.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
20
backend/src/list/list.module.ts
Normal file
20
backend/src/list/list.module.ts
Normal 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 {}
|
||||
137
backend/src/list/list.service.ts
Normal file
137
backend/src/list/list.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
60
backend/src/list/user-work.entity.ts
Normal file
60
backend/src/list/user-work.entity.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user