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:
20
backend/.env.example
Normal file
20
backend/.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Sakuin Backend
|
||||
PORT=4002
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USERNAME=
|
||||
DB_PASSWORD=
|
||||
DB_DATABASE=sakuin
|
||||
|
||||
# SuperOAuth
|
||||
SUPEROAUTH_URL=https://superoauth.tetardtek.com
|
||||
SUPEROAUTH_CLIENT_ID=sakuin
|
||||
SUPEROAUTH_CLIENT_SECRET=
|
||||
|
||||
# AniList (public API — no auth required for search)
|
||||
ANILIST_API_URL=https://graphql.anilist.co
|
||||
|
||||
# Frontend URL (CORS)
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.env
|
||||
*.js.map
|
||||
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
5709
backend/package-lock.json
generated
Normal file
5709
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
backend/package.json
Normal file
38
backend/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "sakuin-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "Sakuin — manga/anime tracker API",
|
||||
"license": "BUSL-1.1",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
|
||||
"migration:generate": "npm run typeorm -- migration:generate src/migrations/$npm_config_name -d src/config/data-source.ts",
|
||||
"migration:run": "npm run typeorm -- migration:run -d src/config/data-source.ts",
|
||||
"migration:revert": "npm run typeorm -- migration:revert -d src/config/data-source.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.17",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.17",
|
||||
"@nestjs/platform-express": "^11.1.17",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"mysql2": "^3.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"typeorm": "^0.3.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
}
|
||||
25
backend/src/app.module.ts
Normal file
25
backend/src/app.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { getDatabaseConfig } from './config/database.config';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { WorkModule } from './work/work.module';
|
||||
import { ListModule } from './list/list.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: getDatabaseConfig,
|
||||
}),
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
UserModule,
|
||||
WorkModule,
|
||||
ListModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
50
backend/src/auth/auth.guard.ts
Normal file
50
backend/src/auth/auth.guard.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
const userInfo = await this.introspect(token);
|
||||
if (!userInfo) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
request.user = userInfo;
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractToken(request: any): string | null {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
return request.cookies?.access_token || null;
|
||||
}
|
||||
|
||||
private async introspect(token: string): Promise<any> {
|
||||
const url = this.configService.get('SUPEROAUTH_URL');
|
||||
try {
|
||||
const res = await fetch(`${url}/api/v1/user/profile`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
backend/src/auth/auth.module.ts
Normal file
8
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
|
||||
@Module({
|
||||
providers: [AuthGuard],
|
||||
exports: [AuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
16
backend/src/config/data-source.ts
Normal file
16
backend/src/config/data-source.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config();
|
||||
|
||||
export default new DataSource({
|
||||
type: 'mysql',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '3306', 10),
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE || 'sakuin',
|
||||
entities: ['src/**/*.entity.ts'],
|
||||
migrations: ['src/migrations/*.ts'],
|
||||
synchronize: false,
|
||||
});
|
||||
17
backend/src/config/database.config.ts
Normal file
17
backend/src/config/database.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export const getDatabaseConfig = (
|
||||
configService: ConfigService,
|
||||
): TypeOrmModuleOptions => ({
|
||||
type: 'mysql',
|
||||
host: configService.get('DB_HOST', 'localhost'),
|
||||
port: configService.get<number>('DB_PORT', 3306),
|
||||
username: configService.get('DB_USERNAME'),
|
||||
password: configService.get('DB_PASSWORD'),
|
||||
database: configService.get('DB_DATABASE', 'sakuin'),
|
||||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
|
||||
synchronize: false,
|
||||
migrationsRun: true,
|
||||
});
|
||||
9
backend/src/health/health.controller.ts
Normal file
9
backend/src/health/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('api/health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return { status: 'ok', service: 'sakuin-backend' };
|
||||
}
|
||||
}
|
||||
7
backend/src/health/health.module.ts
Normal file
7
backend/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
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;
|
||||
}
|
||||
22
backend/src/main.ts
Normal file
22
backend/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, transform: true }),
|
||||
);
|
||||
|
||||
const port = process.env.PORT || 4002;
|
||||
await app.listen(port);
|
||||
console.log(`Sakuin backend listening on :${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
69
backend/src/migrations/1711360000000-Init.ts
Normal file
69
backend/src/migrations/1711360000000-Init.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Init1711360000000 implements MigrationInterface {
|
||||
name = 'Init1711360000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE users (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
superOauthId VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
avatarUrl VARCHAR(512) NULL,
|
||||
xp INT NOT NULL DEFAULT 0,
|
||||
level INT NOT NULL DEFAULT 1,
|
||||
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
UNIQUE INDEX IDX_users_superOauthId (superOauthId),
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE works (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
anilistId INT NOT NULL,
|
||||
type ENUM('anime', 'manga') NOT NULL,
|
||||
titleRomaji VARCHAR(512) NOT NULL,
|
||||
titleEnglish VARCHAR(512) NULL,
|
||||
titleNative VARCHAR(512) NULL,
|
||||
posterUrl VARCHAR(1024) NULL,
|
||||
synopsis TEXT NULL,
|
||||
totalEpisodes INT NULL,
|
||||
totalChapters INT NULL,
|
||||
status ENUM('releasing', 'finished', 'not_yet_released', 'cancelled', 'hiatus') NULL,
|
||||
genres JSON NULL,
|
||||
cachedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
UNIQUE INDEX IDX_works_anilistId (anilistId),
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE user_works (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
userId INT NOT NULL,
|
||||
workId INT NOT NULL,
|
||||
status ENUM('watching', 'reading', 'completed', 'dropped', 'paused', 'plan_to') NOT NULL,
|
||||
progress INT NOT NULL DEFAULT 0,
|
||||
score DECIMAL(3,1) NULL,
|
||||
startedAt DATETIME NULL,
|
||||
completedAt DATETIME NULL,
|
||||
createdAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updatedAt DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
UNIQUE INDEX IDX_user_works_userId_workId (userId, workId),
|
||||
INDEX IDX_user_works_userId (userId),
|
||||
INDEX IDX_user_works_workId (workId),
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT FK_user_works_userId FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT FK_user_works_workId FOREIGN KEY (workId) REFERENCES works(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP TABLE IF EXISTS user_works');
|
||||
await queryRunner.query('DROP TABLE IF EXISTS works');
|
||||
await queryRunner.query('DROP TABLE IF EXISTS users');
|
||||
}
|
||||
}
|
||||
43
backend/src/user/user.controller.ts
Normal file
43
backend/src/user/user.controller.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
|
||||
@Controller('api/user')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AuthGuard)
|
||||
async me(@Req() req: any) {
|
||||
const user = await this.userService.findOrCreate({
|
||||
id: req.user.id,
|
||||
nickname: req.user.nickname,
|
||||
avatar: req.user.avatar,
|
||||
});
|
||||
|
||||
const profile = await this.userService.getProfile(user.id);
|
||||
const stats = this.computeStats(profile);
|
||||
|
||||
return { ...profile, stats };
|
||||
}
|
||||
|
||||
private computeStats(profile: any) {
|
||||
if (!profile?.userWorks) return { total: 0, watching: 0, reading: 0, completed: 0 };
|
||||
|
||||
const works = profile.userWorks;
|
||||
return {
|
||||
total: works.length,
|
||||
watching: works.filter((w: any) => w.status === 'watching').length,
|
||||
reading: works.filter((w: any) => w.status === 'reading').length,
|
||||
completed: works.filter((w: any) => w.status === 'completed').length,
|
||||
dropped: works.filter((w: any) => w.status === 'dropped').length,
|
||||
planTo: works.filter((w: any) => w.status === 'plan_to').length,
|
||||
episodesWatched: works
|
||||
.filter((w: any) => w.work?.type === 'anime')
|
||||
.reduce((sum: number, w: any) => sum + w.progress, 0),
|
||||
chaptersRead: works
|
||||
.filter((w: any) => w.work?.type === 'manga')
|
||||
.reduce((sum: number, w: any) => sum + w.progress, 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
39
backend/src/user/user.entity.ts
Normal file
39
backend/src/user/user.entity.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { UserWork } from '../list/user-work.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
superOauthId: string;
|
||||
|
||||
@Column()
|
||||
username: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
avatarUrl: string;
|
||||
|
||||
@Column({ default: 0 })
|
||||
xp: number;
|
||||
|
||||
@Column({ default: 1 })
|
||||
level: number;
|
||||
|
||||
@OneToMany(() => UserWork, (uw) => uw.user)
|
||||
userWorks: UserWork[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
14
backend/src/user/user.module.ts
Normal file
14
backend/src/user/user.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User]), AuthModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
61
backend/src/user/user.service.ts
Normal file
61
backend/src/user/user.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
) {}
|
||||
|
||||
async findOrCreate(oauthProfile: {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar?: string;
|
||||
}): Promise<User> {
|
||||
let user = await this.userRepo.findOne({
|
||||
where: { superOauthId: oauthProfile.id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = this.userRepo.create({
|
||||
superOauthId: oauthProfile.id,
|
||||
username: oauthProfile.nickname,
|
||||
avatarUrl: oauthProfile.avatar,
|
||||
});
|
||||
return this.userRepo.save(user);
|
||||
}
|
||||
|
||||
if (
|
||||
user.username !== oauthProfile.nickname ||
|
||||
user.avatarUrl !== oauthProfile.avatar
|
||||
) {
|
||||
user.username = oauthProfile.nickname;
|
||||
user.avatarUrl = oauthProfile.avatar || user.avatarUrl;
|
||||
return this.userRepo.save(user);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async getProfile(userId: number) {
|
||||
return this.userRepo.findOne({
|
||||
where: { id: userId },
|
||||
relations: ['userWorks', 'userWorks.work'],
|
||||
});
|
||||
}
|
||||
|
||||
async addXp(userId: number, amount: number): Promise<User> {
|
||||
const user = await this.userRepo.findOneByOrFail({ id: userId });
|
||||
user.xp += amount;
|
||||
user.level = this.calculateLevel(user.xp);
|
||||
return this.userRepo.save(user);
|
||||
}
|
||||
|
||||
private calculateLevel(xp: number): number {
|
||||
// Level up every 500 XP — simple for now, tuneable later
|
||||
return Math.floor(xp / 500) + 1;
|
||||
}
|
||||
}
|
||||
115
backend/src/work/anilist.service.ts
Normal file
115
backend/src/work/anilist.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface AniListMedia {
|
||||
id: number;
|
||||
type: 'ANIME' | 'MANGA';
|
||||
title: { romaji: string; english: string | null; native: string | null };
|
||||
coverImage: { large: string } | null;
|
||||
description: string | null;
|
||||
episodes: number | null;
|
||||
chapters: number | null;
|
||||
status: string | null;
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
interface AniListSearchResult {
|
||||
data: {
|
||||
Page: {
|
||||
media: AniListMedia[];
|
||||
pageInfo: { total: number; hasNextPage: boolean };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const SEARCH_QUERY = `
|
||||
query ($search: String, $type: MediaType, $page: Int, $perPage: Int) {
|
||||
Page(page: $page, perPage: $perPage) {
|
||||
pageInfo { total hasNextPage }
|
||||
media(search: $search, type: $type, sort: POPULARITY_DESC) {
|
||||
id type
|
||||
title { romaji english native }
|
||||
coverImage { large }
|
||||
description
|
||||
episodes chapters
|
||||
status
|
||||
genres
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const GET_BY_ID_QUERY = `
|
||||
query ($id: Int) {
|
||||
Media(id: $id) {
|
||||
id type
|
||||
title { romaji english native }
|
||||
coverImage { large }
|
||||
description
|
||||
episodes chapters
|
||||
status
|
||||
genres
|
||||
}
|
||||
}`;
|
||||
|
||||
@Injectable()
|
||||
export class AniListService {
|
||||
private readonly logger = new Logger(AniListService.name);
|
||||
private readonly apiUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.apiUrl = this.configService.get(
|
||||
'ANILIST_API_URL',
|
||||
'https://graphql.anilist.co',
|
||||
);
|
||||
}
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
type?: 'ANIME' | 'MANGA',
|
||||
page = 1,
|
||||
perPage = 20,
|
||||
): Promise<{ media: AniListMedia[]; total: number; hasNextPage: boolean }> {
|
||||
const variables: Record<string, any> = { search: query, page, perPage };
|
||||
if (type) variables.type = type;
|
||||
|
||||
const result = await this.request<AniListSearchResult>(
|
||||
SEARCH_QUERY,
|
||||
variables,
|
||||
);
|
||||
const pageData = result.data.Page;
|
||||
|
||||
return {
|
||||
media: pageData.media,
|
||||
total: pageData.pageInfo.total,
|
||||
hasNextPage: pageData.pageInfo.hasNextPage,
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id: number): Promise<AniListMedia | null> {
|
||||
try {
|
||||
const result = await this.request<{ data: { Media: AniListMedia } }>(
|
||||
GET_BY_ID_QUERY,
|
||||
{ id },
|
||||
);
|
||||
return result.data.Media;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(query: string, variables: Record<string, any>): Promise<T> {
|
||||
const res = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
this.logger.error(`AniList API error ${res.status}: ${body}`);
|
||||
throw new Error(`AniList API error: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
}
|
||||
19
backend/src/work/work.controller.ts
Normal file
19
backend/src/work/work.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { WorkService } from './work.service';
|
||||
|
||||
@Controller('api/works')
|
||||
export class WorkController {
|
||||
constructor(private readonly workService: WorkService) {}
|
||||
|
||||
@Get('search')
|
||||
async search(
|
||||
@Query('q') query: string,
|
||||
@Query('type') type?: 'ANIME' | 'MANGA',
|
||||
@Query('page') page?: string,
|
||||
) {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return { media: [], total: 0, hasNextPage: false };
|
||||
}
|
||||
return this.workService.search(query.trim(), type, page ? parseInt(page, 10) : 1);
|
||||
}
|
||||
}
|
||||
66
backend/src/work/work.entity.ts
Normal file
66
backend/src/work/work.entity.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { UserWork } from '../list/user-work.entity';
|
||||
|
||||
export enum WorkType {
|
||||
ANIME = 'anime',
|
||||
MANGA = 'manga',
|
||||
}
|
||||
|
||||
export enum WorkStatus {
|
||||
RELEASING = 'releasing',
|
||||
FINISHED = 'finished',
|
||||
NOT_YET_RELEASED = 'not_yet_released',
|
||||
CANCELLED = 'cancelled',
|
||||
HIATUS = 'hiatus',
|
||||
}
|
||||
|
||||
@Entity('works')
|
||||
export class Work {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
anilistId: number;
|
||||
|
||||
@Column({ type: 'enum', enum: WorkType })
|
||||
type: WorkType;
|
||||
|
||||
@Column()
|
||||
titleRomaji: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
titleEnglish: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
titleNative: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
posterUrl: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
synopsis: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
totalEpisodes: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
totalChapters: number;
|
||||
|
||||
@Column({ type: 'enum', enum: WorkStatus, nullable: true })
|
||||
status: WorkStatus;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
genres: string[];
|
||||
|
||||
@OneToMany(() => UserWork, (uw) => uw.work)
|
||||
userWorks: UserWork[];
|
||||
|
||||
@CreateDateColumn()
|
||||
cachedAt: Date;
|
||||
}
|
||||
14
backend/src/work/work.module.ts
Normal file
14
backend/src/work/work.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Work } from './work.entity';
|
||||
import { WorkController } from './work.controller';
|
||||
import { WorkService } from './work.service';
|
||||
import { AniListService } from './anilist.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Work])],
|
||||
controllers: [WorkController],
|
||||
providers: [WorkService, AniListService],
|
||||
exports: [WorkService],
|
||||
})
|
||||
export class WorkModule {}
|
||||
53
backend/src/work/work.service.ts
Normal file
53
backend/src/work/work.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Work, WorkType, WorkStatus } from './work.entity';
|
||||
import { AniListService } from './anilist.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkService {
|
||||
constructor(
|
||||
@InjectRepository(Work)
|
||||
private readonly workRepo: Repository<Work>,
|
||||
private readonly anilist: AniListService,
|
||||
) {}
|
||||
|
||||
async search(query: string, type?: 'ANIME' | 'MANGA', page = 1) {
|
||||
return this.anilist.search(query, type, page);
|
||||
}
|
||||
|
||||
async findOrCreateFromAniList(anilistId: number): Promise<Work> {
|
||||
const existing = await this.workRepo.findOne({ where: { anilistId } });
|
||||
if (existing) return existing;
|
||||
|
||||
const media = await this.anilist.getById(anilistId);
|
||||
if (!media) throw new Error(`AniList media ${anilistId} not found`);
|
||||
|
||||
const work = this.workRepo.create({
|
||||
anilistId: media.id,
|
||||
type: media.type === 'ANIME' ? WorkType.ANIME : WorkType.MANGA,
|
||||
titleRomaji: media.title.romaji,
|
||||
titleEnglish: media.title.english ?? undefined,
|
||||
titleNative: media.title.native ?? undefined,
|
||||
posterUrl: media.coverImage?.large ?? undefined,
|
||||
synopsis: media.description ?? undefined,
|
||||
totalEpisodes: media.episodes ?? undefined,
|
||||
totalChapters: media.chapters ?? undefined,
|
||||
status: this.mapStatus(media.status),
|
||||
genres: media.genres,
|
||||
} as Partial<Work>);
|
||||
|
||||
return this.workRepo.save(work as Work);
|
||||
}
|
||||
|
||||
private mapStatus(status: string | null): WorkStatus | undefined {
|
||||
const map: Record<string, WorkStatus> = {
|
||||
RELEASING: WorkStatus.RELEASING,
|
||||
FINISHED: WorkStatus.FINISHED,
|
||||
NOT_YET_RELEASED: WorkStatus.NOT_YET_RELEASED,
|
||||
CANCELLED: WorkStatus.CANCELLED,
|
||||
HIATUS: WorkStatus.HIATUS,
|
||||
};
|
||||
return status ? map[status] : undefined;
|
||||
}
|
||||
}
|
||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
27
backend/tsconfig.json
Normal file
27
backend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user