Skip to content

Commit 79d5481

Browse files
authored
Merge pull request #20 from gyedongjeon/feature/usage-limits
feat: Usage Limits System (v0.2.0)
2 parents ea7f4b7 + 0815f36 commit 79d5481

13 files changed

Lines changed: 198 additions & 46 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "german-api",
3-
"version": "0.1.6",
3+
"version": "0.2.0",
44
"description": "",
55
"author": "",
66
"private": true,

backend/src/app.module.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ import { AuthModule } from './auth/auth.module';
2121
host: configService.get<string>('POSTGRES_HOST') ?? 'localhost',
2222
port: configService.get<number>('POSTGRES_PORT') ?? 5432,
2323
username: configService.get<string>('POSTGRES_USER') ?? 'myuser',
24-
password: configService.get<string>('POSTGRES_PASSWORD') ?? 'mypassword',
24+
password:
25+
configService.get<string>('POSTGRES_PASSWORD') ?? 'mypassword',
2526
database: configService.get<string>('POSTGRES_DB') ?? 'deutschmock',
26-
ssl: configService.get<string>('POSTGRES_HOST') !== 'localhost' ? { rejectUnauthorized: false } : false,
27+
ssl:
28+
configService.get<string>('POSTGRES_HOST') !== 'localhost'
29+
? { rejectUnauthorized: false }
30+
: false,
2731
autoLoadEntities: true,
2832
synchronize: true, // Auto-create tables (careful in prod, but ok for MVP)
2933
};
@@ -37,4 +41,4 @@ import { AuthModule } from './auth/auth.module';
3741
controllers: [AppController],
3842
providers: [AppService],
3943
})
40-
export class AppModule { }
44+
export class AppModule {}

backend/src/auth/auth.controller.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { AuthService } from './auth.service';
1313

1414
@Controller('auth')
1515
export class AuthController {
16-
constructor(private authService: AuthService) { }
16+
constructor(private authService: AuthService) {}
1717

1818
@Get('google')
1919
@UseGuards(AuthGuard('google'))
@@ -40,7 +40,9 @@ export class AuthController {
4040
});
4141

4242
// Redirect to Frontend (deliver token)
43-
const frontendUrls = (process.env.FRONTEND_URL || 'http://localhost:3000').split(',');
43+
const frontendUrls = (
44+
process.env.FRONTEND_URL || 'http://localhost:3000'
45+
).split(',');
4446
const primaryUrl = frontendUrls[0].trim();
4547
res.redirect(`${primaryUrl}/auth/callback?${params.toString()}`);
4648
}

backend/src/auth/entities/user.entity.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ export class User {
3434
@Column({ default: 'A2' })
3535
level: string;
3636

37+
@Column({ default: 0 })
38+
usage_count: number;
39+
40+
@Column({ default: 5 })
41+
usage_limit: number;
42+
43+
@Column({ nullable: true })
44+
last_usage_date: Date;
45+
3746
@OneToMany(() => Evaluation, (evaluation) => evaluation.user)
3847
evaluations: Evaluation[];
3948

backend/src/evaluation/evaluation.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
33
import { EvaluationService } from './evaluation.service';
44
import { EvaluationController } from './evaluation.controller';
55
import { Evaluation } from './entities/evaluation.entity';
6+
import { User } from '../auth/entities/user.entity';
67
import { AuthModule } from '../auth/auth.module';
78

89
@Module({
9-
imports: [TypeOrmModule.forFeature([Evaluation]), AuthModule],
10+
imports: [TypeOrmModule.forFeature([Evaluation, User]), AuthModule],
1011
controllers: [EvaluationController],
1112
providers: [EvaluationService],
1213
})

backend/src/evaluation/evaluation.service.spec.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Evaluation } from './entities/evaluation.entity';
66
import { Repository } from 'typeorm';
77
import { ConfigService } from '@nestjs/config';
88
import { NotFoundException } from '@nestjs/common';
9+
import { User } from '../auth/entities/user.entity';
910

1011
const mockEvaluationRepository = () => ({
1112
create: jest.fn(),
@@ -22,9 +23,17 @@ type MockRepository<T = any> = Partial<
2223
Record<keyof Repository<any>, jest.Mock>
2324
>;
2425

26+
// Mock User Repository
27+
const mockUserRepository = () => ({
28+
findOne: jest.fn(),
29+
update: jest.fn(),
30+
increment: jest.fn(),
31+
});
32+
2533
describe('EvaluationService', () => {
2634
let service: EvaluationService;
2735
let repository: MockRepository<Evaluation>;
36+
let userRepository: MockRepository<User>;
2837

2938
beforeEach(async () => {
3039
const module: TestingModule = await Test.createTestingModule({
@@ -34,12 +43,17 @@ describe('EvaluationService', () => {
3443
provide: getRepositoryToken(Evaluation),
3544
useFactory: mockEvaluationRepository,
3645
},
46+
{
47+
provide: getRepositoryToken(User),
48+
useFactory: mockUserRepository,
49+
},
3750
{ provide: ConfigService, useValue: mockConfigService },
3851
],
3952
}).compile();
4053

4154
service = module.get<EvaluationService>(EvaluationService);
4255
repository = module.get(getRepositoryToken(Evaluation));
56+
userRepository = module.get(getRepositoryToken(User));
4357
});
4458

4559
it('should be defined', () => {
@@ -92,7 +106,7 @@ describe('EvaluationService', () => {
92106
it('should create an evaluation by calling AI and saving to DB', async () => {
93107
// Mock Data
94108
const createEvaluationDto = {
95-
answer: 'My German Text',
109+
answer: 'My German Text is now long enough to pass validation',
96110
level: 'A2',
97111
part: 1,
98112
module: 'writing',

backend/src/evaluation/evaluation.service.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */
2-
import { Injectable, NotFoundException } from '@nestjs/common';
2+
import {
3+
Injectable,
4+
NotFoundException,
5+
ForbiddenException,
6+
} from '@nestjs/common';
37
import { InjectRepository } from '@nestjs/typeorm';
48
import { Repository } from 'typeorm';
59
import { CreateEvaluationDto } from './dto/create-evaluation.dto';
@@ -19,12 +23,48 @@ export class EvaluationService {
1923
constructor(
2024
@InjectRepository(Evaluation)
2125
private evaluationRepository: Repository<Evaluation>,
26+
@InjectRepository(User)
27+
private userRepository: Repository<User>,
2228
private configService: ConfigService,
23-
) {}
29+
) {
30+
// ...
31+
}
2432

2533
async create(createEvaluationDto: CreateEvaluationDto, user?: User) {
34+
// 1. Check Usage Limit (if user exists)
35+
if (user) {
36+
const freshUser = await this.userRepository.findOne({
37+
where: { id: user.id },
38+
});
39+
if (freshUser) {
40+
const today = new Date().toDateString();
41+
const lastDate = freshUser.last_usage_date
42+
? freshUser.last_usage_date.toDateString()
43+
: null;
44+
45+
// Reset if new day
46+
if (lastDate !== today) {
47+
freshUser.usage_count = 0;
48+
// We don't save immediately here to save a DB call, we trust the check below
49+
}
50+
51+
if (freshUser.usage_count >= freshUser.usage_limit) {
52+
throw new ForbiddenException(
53+
'Daily trial limit reached (10/10). Please upgrade or try again tomorrow.',
54+
);
55+
}
56+
}
57+
}
58+
2659
const { answer, task } = createEvaluationDto;
2760

61+
// Validation: Minimum Length
62+
if (!answer || answer.trim().length < 20) {
63+
throw new ForbiddenException(
64+
'Answer is too short. Please write at least 20 characters to proceed.',
65+
);
66+
}
67+
2868
// Use level/part from task or DTO if available, defaults provided
2969
const level = task?.level || createEvaluationDto.level || 'A2';
3070
const part = task?.part || createEvaluationDto.part || 1;
@@ -37,6 +77,40 @@ export class EvaluationService {
3777
level,
3878
);
3979

80+
// 2. Increment Usage Count & Update Date (Only on successful AI call)
81+
if (user) {
82+
// We perform a safe update that handles the reset implicitly by setting the value
83+
const freshUser = await this.userRepository.findOne({
84+
where: { id: user.id },
85+
});
86+
if (freshUser) {
87+
const today = new Date();
88+
const lastDateStr = freshUser.last_usage_date
89+
? freshUser.last_usage_date.toDateString()
90+
: null;
91+
92+
if (lastDateStr !== today.toDateString()) {
93+
// First use of the day
94+
await this.userRepository.update(
95+
{ id: user.id },
96+
{ usage_count: 1, last_usage_date: today },
97+
);
98+
} else {
99+
// Same day, just increment
100+
await this.userRepository.increment(
101+
{ id: user.id },
102+
'usage_count',
103+
1,
104+
);
105+
// Ensure date is current (though distinct days are handled above, keeping it fresh is fine)
106+
await this.userRepository.update(
107+
{ id: user.id },
108+
{ last_usage_date: today },
109+
);
110+
}
111+
}
112+
}
113+
40114
// Save to DB
41115
const evaluation = this.evaluationRepository.create({
42116
original_text: answer,

backend/src/evaluation/prompts/evaluation.prompt.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export const generateEvaluationPrompt = (
9797
}
9898
}
9999
100-
Important: Provide the feedback (strengths and improvements) in ${languageName}.
100+
Important: Provide the feedback (strengths and improvements) in ${languageName} ONLY. Do NOT provide the German translation in parentheses.
101+
Example: "This SMS does not cover all points." (Good)
102+
Example: "This SMS does not cover all points. (Diese SMS behandelt nicht alle Punkte.)" (BAD - Do not do this)
101103
`;
102104
};

backend/src/evaluation/prompts/task.prompt.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,16 +94,18 @@ export const generateTaskPrompt = (
9494
9595
The task should be realistic and suitable for ${level} proficiency.
9696
97+
CRITICAL: You MUST include a specific name for the recipient in the 'scenario' or 'instruction' (e.g. 'Ihre Lehrerin Frau Müller', 'Ihr Freund Thomas', 'das Hotel Stern'). Do NOT say just 'your teacher' or 'a friend'. Give them a NAME.
98+
9799
Return the task in the following JSON format ONLY:
98100
{
99101
"title": "Topic Title (German)",
100-
"scenario": "A short description of the situation in German (Sie sind ...).",
101-
"points": [
102-
"First point to cover (German)",
103-
"Second point to cover (German)",
104-
"Third point to cover (German)"
105-
],
106-
"instruction": "Schreiben Sie ca. ${partSpec.length}. Schreiben Sie zu allen Punkten.",
102+
"scenario": "A description of the situation in German (Sie sind ...). IMPORTANT: You MUST specify a name for the recipient (e.g. 'Ihre Lehrerin Frau Müller', 'Ihr Freund Thomas', 'das Hotel Stern').",
103+
"points": [
104+
"First point to cover (German)",
105+
"Second point to cover (German)",
106+
"Third point to cover (German)"
107+
],
108+
"instruction": "Schreiben Sie eine E-Mail/SMS an [Name]. Schreiben Sie ca. ${partSpec.length}. Schreiben Sie zu allen Punkten.",
107109
"time": "${partSpec.time}"
108110
}
109111
`;

backend/src/main.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,20 @@ import { AppModule } from './app.module';
77
async function bootstrap() {
88
const app = await NestFactory.create(AppModule);
99
app.enableCors({
10-
origin: (origin: string, callback: (err: Error | null, allow?: boolean) => void) => {
11-
const allowedOrigins = (process.env.FRONTEND_URL || '').split(',').map(url => url.trim());
10+
origin: (
11+
origin: string,
12+
callback: (err: Error | null, allow?: boolean) => void,
13+
) => {
14+
const allowedOrigins = (process.env.FRONTEND_URL || '')
15+
.split(',')
16+
.map((url) => url.trim());
1217
allowedOrigins.push('http://localhost:3000');
1318

14-
if (!origin || allowedOrigins.includes(origin) || /\.vercel\.app$/.test(origin)) {
19+
if (
20+
!origin ||
21+
allowedOrigins.includes(origin) ||
22+
/\.vercel\.app$/.test(origin)
23+
) {
1524
callback(null, true);
1625
} else {
1726
console.warn(`Blocked CORS for origin: ${origin}`);

0 commit comments

Comments
 (0)