Skip to content

Commit 74da90f

Browse files
authored
Merge pull request #345 from OmniZlatoon/#162-Implement-email-template-service
#162 implement email template service
2 parents fd25e35 + 8189a4f commit 74da90f

13 files changed

Lines changed: 290 additions & 124 deletions

File tree

backend/.env.example

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1+
#Mock data of the original .env file for development purposes. Do not use in production.
2+
13
# ── Required ─────────────────────────────────────────────────────────────────
24
# These must be set or the app will fail at startup with a validation error.
35

46
# Supabase — required for metadata service
57
SUPABASE_URL=https://your-project.supabase.co
6-
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
8+
SUPABASE_SERVICE_ROLE_KEY=YOUR-SUPERBASE-SERVICE-ROLE
79

810
# JWT — for SIWS-issued tokens (min 32 characters)
9-
JWT_SECRET=your-secret-at-least-32-chars
11+
JWT_SECRET=RANDOM-STRINGS-OFCHARACTERS
1012

1113
# Admin dashboard — bearer token for /admin/* endpoints (required)
12-
ADMIN_TOKEN=change-me-to-a-long-random-secret
14+
ADMIN_TOKEN=long-random-strings-of character
1315

1416
# Admin IP allowlist — comma-separated CIDRs/IPs; empty string allows all IPs
1517
ADMIN_IP_ALLOWLIST=

backend/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
.env.local
55
*.log
66
package-lock.json
7+
package.json

backend/nest-cli.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
"collection": "@nestjs/schematics",
44
"sourceRoot": "src",
55
"compilerOptions": {
6-
"deleteOutDir": true
6+
"deleteOutDir": true,
7+
"assets": [
8+
{
9+
"include": "assets/templates/**/*",
10+
"outDir": "dist/assets/templates",
11+
"watchAssets": true
12+
}
13+
]
714
}
8-
}
15+
}

backend/package.json

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,13 @@
1313
"test:e2e": "jest --config ./test/jest-e2e.json"
1414
},
1515
"dependencies": {
16-
"@fastify/helmet": "^11.1.1",
1716
"@fastify/multipart": "^9.0.0",
18-
"@fastify/static": "^9.0.0",
19-
"@fastify/swagger": "^9.7.0",
20-
"@fastify/swagger-ui": "^5.2.5",
2117
"@nestjs/common": "^10.4.0",
22-
"@nestjs/config": "^3.2.0",
18+
"@nestjs/config": "^4.0.3",
2319
"@nestjs/core": "^10.4.0",
2420
"@nestjs/jwt": "^10.2.0",
2521
"@nestjs/passport": "^10.0.3",
26-
"@nestjs/platform-fastify": "^10.4.0",
27-
"@nestjs/swagger": "^11.2.6",
22+
"@nestjs/platform-fastify": "^11.1.17",
2823
"@nestjs/throttler": "^6.4.0",
2924
"@nestjs/typeorm": "^11.0.0",
3025
"@stellar/stellar-sdk": "^14.4.0",
@@ -38,8 +33,7 @@
3833
"reflect-metadata": "^0.2.0",
3934
"rxjs": "^7.8.0",
4035
"typeorm": "^0.3.28",
41-
"zod": "^3.23.0",
42-
"firebase-admin": "^11.9.0"
36+
"zod": "^3.23.0"
4337
},
4438
"jest": {
4539
"moduleFileExtensions": [
@@ -55,7 +49,7 @@
5549
"testEnvironment": "node"
5650
},
5751
"devDependencies": {
58-
"@nestjs/cli": "^10.4.0",
52+
"@nestjs/cli": "^11.0.16",
5953
"@nestjs/testing": "^10.4.0",
6054
"@types/jest": "^29.5.0",
6155
"@types/node": "^22.0.0",

backend/src/app.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { validate } from "./config/env.schema";
3737
* THROTTLE_NONCE_LIMIT / THROTTLE_NONCE_TTL
3838
*/
3939
ThrottlerModule.forRootAsync({
40-
imports: [ConfigModule],
40+
imports: [ConfigModule.forRoot()],
4141
inject: [ConfigService],
4242
useFactory: (config: ConfigService) => ({
4343
throttlers: [
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<style>
6+
body { font-family: 'Helvetica Neue', Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 0; }
7+
.container { max-width: 600px; margin: 20px auto; background: #ffffff; border-radius: 12px; overflow: hidden; }
8+
.header { background: #1f2937; color: white; padding: 30px; text-align: center; }
9+
.content { padding: 30px; color: #4b5563; line-height: 1.6; }
10+
.status-badge { display: inline-block; padding: 4px 12px; background: #fee2e2; color: #dc2626; border-radius: 99px; font-size: 14px; font-weight: bold; }
11+
.footer { background: #f9fafb; padding: 20px; text-align: center; font-size: 12px; }
12+
</style>
13+
</head>
14+
<body>
15+
<div class="container">
16+
<div class="header">
17+
<h2>Raffle Competition Ended</h2>
18+
</div>
19+
<div class="content">
20+
<div class="status-badge">COMPLETED</div>
21+
<p>Hello,</p>
22+
<p>The entry period for <strong>{{raffleName}}</strong> has officially closed.</p>
23+
<p>Our system is currently finalizing the results. You can check the final leaderboard and see the winner by clicking below.</p>
24+
<a href="{{resultsUrl}}" style="color: #6366f1; font-weight: bold;">View Final Results &rarr;</a>
25+
</div>
26+
<div class="footer">
27+
<p>Thank you for participating in Tikka!</p>
28+
</div>
29+
</div>
30+
</body>
31+
</html>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8">
5+
<style>
6+
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 0; }
7+
.container { max-width: 600px; margin: 20px auto; background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.1); }
8+
.header { background: #6366f1; color: white; padding: 40px 20px; text-align: center; }
9+
.content { padding: 30px; text-align: center; color: #333333; }
10+
.prize-box { background: #f9fafb; border: 2px dashed #6366f1; border-radius: 8px; padding: 20px; margin: 20px 0; }
11+
.button { display: inline-block; padding: 14px 28px; background-color: #6366f1; color: white; text-decoration: none; border-radius: 6px; font-weight: bold; margin-top: 20px; }
12+
.footer { padding: 20px; text-align: center; font-size: 12px; color: #9ca3af; }
13+
</style>
14+
</head>
15+
<body>
16+
<div class="container">
17+
<div class="header">
18+
<h1>🏆 YOU WON!</h1>
19+
</div>
20+
<div class="content">
21+
<p>Hello <strong>{{username}}</strong>,</p>
22+
<p>The wait is over! You have been selected as the winner for the raffle:</p>
23+
<div class="prize-box">
24+
<h2 style="margin:0; color: #6366f1;">{{raffleName}}</h2>
25+
</div>
26+
<p>Click the button below to claim your prize and see the details.</p>
27+
<a href="{{claimUrl}}" class="button">Claim My Prize</a>
28+
</div>
29+
<div class="footer">
30+
<p>&copy; 2026 Tikka Platform. All rights reserved.</p>
31+
</div>
32+
</div>
33+
</body>
34+
</html>

backend/src/main.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import { MAX_UPLOAD_BYTES } from "./config/upload.config";
1111
import { RequestLoggingInterceptor } from "./middleware/request-logging.interceptor";
1212

1313
async function bootstrap() {
14-
const app = await NestFactory.create<NestFastifyApplication>(
14+
// Avoid generic constraints mismatch between Nest Fastify and Cors types
15+
const app = (await NestFactory.create(
1516
AppModule,
16-
new FastifyAdapter(),
17-
);
17+
new FastifyAdapter() as any,
18+
)) as NestFastifyApplication;
1819

1920
const config = new DocumentBuilder()
2021
.setTitle("Tikka API")
@@ -29,7 +30,7 @@ async function bootstrap() {
2930
await configureSecurity(app);
3031

3132
// Using 'as any' bypasses the type mismatch error between Fastify versions
32-
await app.register(multipart as any, {
33+
await (app as any).register(multipart as any, {
3334
limits: {
3435
fileSize: MAX_UPLOAD_BYTES,
3536
files: 1,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { EmailTemplateService } from './email-template.service';
3+
import { InternalServerErrorException } from '@nestjs/common';
4+
import * as fs from 'fs';
5+
import * as path from 'path';
6+
7+
// Mock the fs module so we don't hit the real disk
8+
jest.mock('fs');
9+
10+
describe('EmailTemplateService', () => {
11+
let service: EmailTemplateService;
12+
13+
beforeEach(async () => {
14+
const module: TestingModule = await Test.createTestingModule({
15+
providers: [EmailTemplateService],
16+
}).compile();
17+
18+
service = module.get<EmailTemplateService>(EmailTemplateService);
19+
});
20+
21+
afterEach(() => {
22+
jest.clearAllMocks();
23+
jest.restoreAllMocks();
24+
});
25+
26+
it('should be defined', () => {
27+
expect(service).toBeDefined();
28+
});
29+
30+
describe('render', () => {
31+
it('should successfully render a template with context', () => {
32+
const templateName = 'Winner';
33+
const context = { username: 'Clinton' };
34+
const mockHbsContent = 'Hello {{username}}';
35+
36+
// Mock fs to say the file exists and return our fake string
37+
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
38+
jest.spyOn(fs, 'readFileSync').mockReturnValue(mockHbsContent);
39+
40+
const result = service.render(templateName, context);
41+
42+
expect(result).toBe('Hello Clinton');
43+
expect(fs.existsSync).toHaveBeenCalled();
44+
expect(fs.readFileSync).toHaveBeenCalled();
45+
});
46+
47+
it('should throw InternalServerErrorException if template does not exist', () => {
48+
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
49+
50+
expect(() => {
51+
service.render('non-existent', {});
52+
}).toThrow(InternalServerErrorException);
53+
});
54+
55+
it('should throw InternalServerErrorException if handlebars fails to compile', () => {
56+
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
57+
jest.spyOn(fs, 'readFileSync').mockReturnValue('Hello {{username');
58+
59+
expect(() => {
60+
service.render('broken-template', { username: 'Clinton' });
61+
}).toThrow(InternalServerErrorException);
62+
});
63+
});
64+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
2+
import * as handlebars from 'handlebars';
3+
import * as fs from 'fs';
4+
import * as path from 'path';
5+
6+
@Injectable()
7+
export class EmailTemplateService {
8+
private readonly logger = new Logger(EmailTemplateService.name);
9+
10+
/**
11+
* Renders a handlebars template with the provided context
12+
* @param templateName Name of the file (without .hbs)
13+
* @param context Data to inject into the template
14+
*/
15+
render(templateName: string, context: any): string {
16+
try {
17+
// 1. Resolve the path to the template file
18+
// Note: We look in 'dist' because that's where the compiled code runs from
19+
const templatePath = path.join(process.cwd(), 'assets', 'templates', `${templateName}.hbs`);
20+
21+
// 2. Read the file content
22+
if (!fs.existsSync(templatePath)) {
23+
this.logger.error(`Template not found at path: ${templatePath}`);
24+
throw new InternalServerErrorException(`Email template ${templateName} not found`);
25+
}
26+
27+
const source = fs.readFileSync(templatePath, 'utf8');
28+
29+
// 3. Compile the template
30+
const template = handlebars.compile(source);
31+
32+
// 4. Return the generated HTML string
33+
return template(context);
34+
} catch (error) {
35+
this.logger.error(`Error rendering template ${templateName}:`, error.stack);
36+
throw new InternalServerErrorException('Failed to render email template');
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)