create-arktos 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +71 -10
- package/eslint.config.js.template +31 -0
- package/package.json +4 -1
- package/src-js/app.js +97 -0
- package/src-js/config/env.validation.js +15 -0
- package/src-js/config/logger.js +76 -0
- package/src-js/constants/errorCodes.js +57 -0
- package/src-js/constants/messages.js +240 -0
- package/src-js/controllers/auth.controller.js +533 -0
- package/src-js/middleware/index.js +284 -0
- package/src-js/routes/auth.routes.js +28 -0
- package/src-js/routes/index.js +18 -0
- package/src-js/schemas/index.js +60 -0
- package/src-js/services/database.service.js +89 -0
- package/src-js/services/email.service.js +161 -0
- package/src-js/services/jwt.service.js +177 -0
- package/src-js/utils/response.js +53 -0
- package/src-js/views/emails/notification.html +87 -0
- package/src-js/views/emails/resetPassword.html +118 -0
- package/src-js/views/emails/verification.html +107 -0
- package/src-js/views/emails/welcome.html +113 -0
- package/template.js.package.json +77 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import helmet from 'helmet';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import rateLimit from 'express-rate-limit';
|
|
4
|
+
import jwt from 'jsonwebtoken';
|
|
5
|
+
import { createErrorResponse } from '../utils/response.js';
|
|
6
|
+
import { ERROR_CODES } from '../constants/errorCodes.js';
|
|
7
|
+
import DatabaseService from '../services/database.service.js';
|
|
8
|
+
import logger from '../config/logger.js';
|
|
9
|
+
|
|
10
|
+
const dbService = DatabaseService.getInstance();
|
|
11
|
+
const prisma = dbService.getClient();
|
|
12
|
+
|
|
13
|
+
// Security middleware
|
|
14
|
+
export const securityMiddleware = [
|
|
15
|
+
helmet({
|
|
16
|
+
contentSecurityPolicy: {
|
|
17
|
+
directives: {
|
|
18
|
+
defaultSrc: ["'self'"],
|
|
19
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
20
|
+
scriptSrc: ["'self'"],
|
|
21
|
+
imgSrc: ["'self'", 'data:', 'https:'],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
crossOriginEmbedderPolicy: false,
|
|
25
|
+
}),
|
|
26
|
+
cors({
|
|
27
|
+
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
|
28
|
+
credentials: true,
|
|
29
|
+
optionsSuccessStatus: 200,
|
|
30
|
+
}),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Rate limiters
|
|
34
|
+
export const rateLimiter = {
|
|
35
|
+
general: rateLimit({
|
|
36
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
37
|
+
max: 100, // limit each IP to 100 requests per windowMs
|
|
38
|
+
message: createErrorResponse(
|
|
39
|
+
'Too many requests, please try again later',
|
|
40
|
+
ERROR_CODES.RATE_LIMIT_EXCEEDED
|
|
41
|
+
),
|
|
42
|
+
standardHeaders: true,
|
|
43
|
+
legacyHeaders: false,
|
|
44
|
+
}),
|
|
45
|
+
|
|
46
|
+
auth: rateLimit({
|
|
47
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
48
|
+
max: 10, // limit each IP to 10 auth requests per windowMs
|
|
49
|
+
message: createErrorResponse(
|
|
50
|
+
'Too many authentication attempts, please try again later',
|
|
51
|
+
ERROR_CODES.RATE_LIMIT_EXCEEDED
|
|
52
|
+
),
|
|
53
|
+
standardHeaders: true,
|
|
54
|
+
legacyHeaders: false,
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
upload: rateLimit({
|
|
58
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
59
|
+
max: 20, // limit each IP to 20 upload requests per windowMs
|
|
60
|
+
message: createErrorResponse(
|
|
61
|
+
'Too many upload requests, please try again later',
|
|
62
|
+
ERROR_CODES.RATE_LIMIT_EXCEEDED
|
|
63
|
+
),
|
|
64
|
+
standardHeaders: true,
|
|
65
|
+
legacyHeaders: false,
|
|
66
|
+
}),
|
|
67
|
+
|
|
68
|
+
api: rateLimit({
|
|
69
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
70
|
+
max: 200, // limit each IP to 200 API requests per windowMs
|
|
71
|
+
message: createErrorResponse(
|
|
72
|
+
'Too many API requests, please try again later',
|
|
73
|
+
ERROR_CODES.RATE_LIMIT_EXCEEDED
|
|
74
|
+
),
|
|
75
|
+
standardHeaders: true,
|
|
76
|
+
legacyHeaders: false,
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Authentication middleware
|
|
81
|
+
export const authMiddleware = async (req, res, next) => {
|
|
82
|
+
try {
|
|
83
|
+
const authHeader = req.headers.authorization;
|
|
84
|
+
|
|
85
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
86
|
+
return res.status(401).json(createErrorResponse(
|
|
87
|
+
'Authentication token required',
|
|
88
|
+
ERROR_CODES.AUTH_TOKEN_REQUIRED
|
|
89
|
+
));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const token = authHeader.substring(7);
|
|
93
|
+
|
|
94
|
+
let decoded;
|
|
95
|
+
try {
|
|
96
|
+
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return res.status(401).json(createErrorResponse(
|
|
99
|
+
'Invalid or expired token',
|
|
100
|
+
ERROR_CODES.AUTH_TOKEN_EXPIRED
|
|
101
|
+
));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Get user from database
|
|
105
|
+
const user = await prisma.user.findUnique({
|
|
106
|
+
where: { id: decoded.userId },
|
|
107
|
+
select: {
|
|
108
|
+
id: true,
|
|
109
|
+
email: true,
|
|
110
|
+
firstName: true,
|
|
111
|
+
lastName: true,
|
|
112
|
+
username: true,
|
|
113
|
+
avatar: true,
|
|
114
|
+
role: true,
|
|
115
|
+
isEmailVerified: true,
|
|
116
|
+
isActive: true,
|
|
117
|
+
lastLoginAt: true,
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!user) {
|
|
122
|
+
return res.status(401).json(createErrorResponse(
|
|
123
|
+
'User not found',
|
|
124
|
+
ERROR_CODES.AUTH_USER_NOT_FOUND
|
|
125
|
+
));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!user.isActive) {
|
|
129
|
+
return res.status(403).json(createErrorResponse(
|
|
130
|
+
'Account is deactivated',
|
|
131
|
+
ERROR_CODES.AUTH_ACCOUNT_DEACTIVATED
|
|
132
|
+
));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
req.user = user;
|
|
136
|
+
next();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
logger.error('Authentication middleware error:', error);
|
|
139
|
+
return res.status(500).json(createErrorResponse(
|
|
140
|
+
'Internal server error',
|
|
141
|
+
ERROR_CODES.INTERNAL_ERROR
|
|
142
|
+
));
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Optional auth middleware (doesn't fail if no token)
|
|
147
|
+
export const optionalAuth = async (req, res, next) => {
|
|
148
|
+
try {
|
|
149
|
+
const authHeader = req.headers.authorization;
|
|
150
|
+
|
|
151
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
152
|
+
return next();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const token = authHeader.substring(7);
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
159
|
+
|
|
160
|
+
const user = await prisma.user.findUnique({
|
|
161
|
+
where: { id: decoded.userId },
|
|
162
|
+
select: {
|
|
163
|
+
id: true,
|
|
164
|
+
email: true,
|
|
165
|
+
firstName: true,
|
|
166
|
+
lastName: true,
|
|
167
|
+
username: true,
|
|
168
|
+
avatar: true,
|
|
169
|
+
role: true,
|
|
170
|
+
isEmailVerified: true,
|
|
171
|
+
isActive: true,
|
|
172
|
+
lastLoginAt: true,
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (user && user.isActive) {
|
|
177
|
+
req.user = user;
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// Ignore token errors in optional auth
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
next();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
logger.error('Optional auth middleware error:', error);
|
|
186
|
+
next();
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Role-based access control
|
|
191
|
+
export const requireRole = (roles) => {
|
|
192
|
+
return (req, res, next) => {
|
|
193
|
+
if (!req.user) {
|
|
194
|
+
return res.status(401).json(createErrorResponse(
|
|
195
|
+
'Authentication required',
|
|
196
|
+
ERROR_CODES.AUTH_TOKEN_REQUIRED
|
|
197
|
+
));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const userRoles = Array.isArray(req.user.role) ? req.user.role : [req.user.role];
|
|
201
|
+
const requiredRoles = Array.isArray(roles) ? roles : [roles];
|
|
202
|
+
|
|
203
|
+
const hasRole = requiredRoles.some(role => userRoles.includes(role));
|
|
204
|
+
|
|
205
|
+
if (!hasRole) {
|
|
206
|
+
return res.status(403).json(createErrorResponse(
|
|
207
|
+
'Insufficient permissions',
|
|
208
|
+
ERROR_CODES.AUTH_INSUFFICIENT_PERMISSIONS
|
|
209
|
+
));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
next();
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Admin only middleware
|
|
217
|
+
export const adminOnly = requireRole(['ADMIN']);
|
|
218
|
+
|
|
219
|
+
// Moderator or admin middleware
|
|
220
|
+
export const moderatorOrAdmin = requireRole(['MODERATOR', 'ADMIN']);
|
|
221
|
+
|
|
222
|
+
// Validation middleware
|
|
223
|
+
export const validation = {
|
|
224
|
+
request: (schema, data) => {
|
|
225
|
+
try {
|
|
226
|
+
return schema.parse(data);
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const errorMessage = error.errors
|
|
229
|
+
? error.errors.map(err => `${err.path.join('.')}: ${err.message}`).join(', ')
|
|
230
|
+
: 'Validation failed';
|
|
231
|
+
|
|
232
|
+
throw new Error(errorMessage);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Error handling middleware
|
|
238
|
+
export const errorHandling = (error, _req, res, _next) => {
|
|
239
|
+
logger.error('Unhandled error:', error);
|
|
240
|
+
|
|
241
|
+
// Prisma errors
|
|
242
|
+
if (error.code === 'P2002') {
|
|
243
|
+
return res.status(409).json(createErrorResponse(
|
|
244
|
+
'Duplicate entry',
|
|
245
|
+
ERROR_CODES.DATABASE_CONFLICT
|
|
246
|
+
));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (error.code === 'P2025') {
|
|
250
|
+
return res.status(404).json(createErrorResponse(
|
|
251
|
+
'Record not found',
|
|
252
|
+
ERROR_CODES.DATABASE_NOT_FOUND
|
|
253
|
+
));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Validation errors
|
|
257
|
+
if (error.name === 'ValidationError' || error.name === 'ZodError') {
|
|
258
|
+
return res.status(400).json(createErrorResponse(
|
|
259
|
+
error.message || 'Validation failed',
|
|
260
|
+
ERROR_CODES.VALIDATION_ERROR
|
|
261
|
+
));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// JWT errors
|
|
265
|
+
if (error.name === 'JsonWebTokenError') {
|
|
266
|
+
return res.status(401).json(createErrorResponse(
|
|
267
|
+
'Invalid token',
|
|
268
|
+
ERROR_CODES.AUTH_TOKEN_INVALID
|
|
269
|
+
));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (error.name === 'TokenExpiredError') {
|
|
273
|
+
return res.status(401).json(createErrorResponse(
|
|
274
|
+
'Token expired',
|
|
275
|
+
ERROR_CODES.AUTH_TOKEN_EXPIRED
|
|
276
|
+
));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Default error response
|
|
280
|
+
return res.status(500).json(createErrorResponse(
|
|
281
|
+
'Internal server error',
|
|
282
|
+
ERROR_CODES.INTERNAL_ERROR
|
|
283
|
+
));
|
|
284
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import {
|
|
3
|
+
register,
|
|
4
|
+
login,
|
|
5
|
+
refreshToken,
|
|
6
|
+
logout,
|
|
7
|
+
verifyEmail,
|
|
8
|
+
getProfile,
|
|
9
|
+
updateProfile,
|
|
10
|
+
changePassword
|
|
11
|
+
} from '../controllers/auth.controller.js';
|
|
12
|
+
import { authMiddleware, optionalAuth, rateLimiter } from '../middleware/index.js';
|
|
13
|
+
|
|
14
|
+
const router = express.Router();
|
|
15
|
+
|
|
16
|
+
// Public routes with rate limiting
|
|
17
|
+
router.post('/register', rateLimiter.auth, register);
|
|
18
|
+
router.post('/login', rateLimiter.auth, login);
|
|
19
|
+
router.post('/refresh-token', rateLimiter.auth, refreshToken);
|
|
20
|
+
router.get('/verify-email/:token', verifyEmail);
|
|
21
|
+
|
|
22
|
+
// Protected routes
|
|
23
|
+
router.post('/logout', authMiddleware, logout);
|
|
24
|
+
router.get('/profile', authMiddleware, getProfile);
|
|
25
|
+
router.put('/profile', authMiddleware, updateProfile);
|
|
26
|
+
router.put('/change-password', authMiddleware, changePassword);
|
|
27
|
+
|
|
28
|
+
export default router;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import authRoutes from './auth.routes.js';
|
|
3
|
+
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
|
|
6
|
+
// Mount auth routes
|
|
7
|
+
router.use('/auth', authRoutes);
|
|
8
|
+
|
|
9
|
+
// Health check for API
|
|
10
|
+
router.get('/health', (req, res) => {
|
|
11
|
+
res.json({
|
|
12
|
+
status: 'ok',
|
|
13
|
+
timestamp: new Date().toISOString(),
|
|
14
|
+
message: 'API is running'
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default router;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Environment validation schema
|
|
4
|
+
export const envSchema = z.object({
|
|
5
|
+
NODE_ENV: z.string().default('development'),
|
|
6
|
+
PORT: z.string().transform(Number).default(3001),
|
|
7
|
+
DATABASE_URL: z.string().min(1, 'Database URL is required'),
|
|
8
|
+
DIRECT_URL: z.string().optional(),
|
|
9
|
+
JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
|
|
10
|
+
JWT_REFRESH_SECRET: z.string().min(32, 'JWT refresh secret must be at least 32 characters'),
|
|
11
|
+
RESEND_API_KEY: z.string().optional(),
|
|
12
|
+
FRONTEND_URL: z.string().default('http://localhost:3000'),
|
|
13
|
+
BACKEND_URL: z.string().default('http://localhost:3001'),
|
|
14
|
+
BCRYPT_SALT_ROUNDS: z.string().transform(Number).default(12),
|
|
15
|
+
LOG_LEVEL: z.string().default('info'),
|
|
16
|
+
LOG_FILE: z.string().default('logs/app.log'),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Auth schemas
|
|
20
|
+
export const registerSchema = z.object({
|
|
21
|
+
email: z.string().email('Invalid email format'),
|
|
22
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
23
|
+
firstName: z.string().min(1, 'First name is required'),
|
|
24
|
+
lastName: z.string().min(1, 'Last name is required'),
|
|
25
|
+
username: z.string().min(3, 'Username must be at least 3 characters').optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const loginSchema = z.object({
|
|
29
|
+
email: z.string().email('Invalid email format'),
|
|
30
|
+
password: z.string().min(1, 'Password is required'),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const refreshTokenSchema = z.object({
|
|
34
|
+
refreshToken: z.string().min(1, 'Refresh token is required'),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const resendVerificationSchema = z.object({
|
|
38
|
+
email: z.string().email('Invalid email format'),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const forgotPasswordSchema = z.object({
|
|
42
|
+
email: z.string().email('Invalid email format'),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const resetPasswordSchema = z.object({
|
|
46
|
+
token: z.string().min(1, 'Reset token is required'),
|
|
47
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const updateProfileSchema = z.object({
|
|
51
|
+
firstName: z.string().min(1, 'First name is required').optional(),
|
|
52
|
+
lastName: z.string().min(1, 'Last name is required').optional(),
|
|
53
|
+
username: z.string().min(3, 'Username must be at least 3 characters').optional(),
|
|
54
|
+
avatar: z.string().url('Invalid avatar URL').optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const changePasswordSchema = z.object({
|
|
58
|
+
currentPassword: z.string().min(1, 'Current password is required'),
|
|
59
|
+
newPassword: z.string().min(8, 'New password must be at least 8 characters'),
|
|
60
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { PrismaClient } from '@prisma/client';
|
|
2
|
+
import logger from '../config/logger.js';
|
|
3
|
+
|
|
4
|
+
export class DatabaseService {
|
|
5
|
+
static instance;
|
|
6
|
+
prisma;
|
|
7
|
+
isConnected = false;
|
|
8
|
+
|
|
9
|
+
constructor() {
|
|
10
|
+
this.prisma = new PrismaClient({
|
|
11
|
+
log: ['query', 'error', 'info', 'warn'],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Set up event listeners
|
|
15
|
+
this.setupEventListeners();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static getInstance() {
|
|
19
|
+
if (!DatabaseService.instance) {
|
|
20
|
+
DatabaseService.instance = new DatabaseService();
|
|
21
|
+
}
|
|
22
|
+
return DatabaseService.instance;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setupEventListeners() {
|
|
26
|
+
// Event listeners can be added here if needed
|
|
27
|
+
// For now, we're using simple logging configuration
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async connect() {
|
|
31
|
+
try {
|
|
32
|
+
await this.prisma.$connect();
|
|
33
|
+
this.isConnected = true;
|
|
34
|
+
logger.info('Database connected successfully');
|
|
35
|
+
} catch (error) {
|
|
36
|
+
this.isConnected = false;
|
|
37
|
+
logger.error('Failed to connect to database:', error);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async disconnect() {
|
|
43
|
+
try {
|
|
44
|
+
await this.prisma.$disconnect();
|
|
45
|
+
this.isConnected = false;
|
|
46
|
+
logger.info('Database disconnected successfully');
|
|
47
|
+
} catch (error) {
|
|
48
|
+
logger.error('Failed to disconnect from database:', error);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getClient() {
|
|
54
|
+
if (!this.isConnected) {
|
|
55
|
+
logger.warn('Database client requested but not connected');
|
|
56
|
+
}
|
|
57
|
+
return this.prisma;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async healthCheck() {
|
|
61
|
+
try {
|
|
62
|
+
await this.prisma.$queryRaw`SELECT 1`;
|
|
63
|
+
return {
|
|
64
|
+
connected: true,
|
|
65
|
+
status: 'healthy',
|
|
66
|
+
timestamp: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.error('Database health check failed:', error);
|
|
70
|
+
return {
|
|
71
|
+
connected: false,
|
|
72
|
+
status: 'unhealthy',
|
|
73
|
+
error: error.message,
|
|
74
|
+
timestamp: new Date().toISOString(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async executeTransaction(callback) {
|
|
80
|
+
try {
|
|
81
|
+
return await this.prisma.$transaction(callback);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
logger.error('Database transaction failed:', error);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default DatabaseService;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Resend } from 'resend';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import logger from '../config/logger.js';
|
|
5
|
+
|
|
6
|
+
const resend = new Resend(process.env.RESEND_API_KEY);
|
|
7
|
+
|
|
8
|
+
class EmailService {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.fromEmail = process.env.FROM_EMAIL || 'noreply@arktos.dev';
|
|
11
|
+
this.templatesPath = path.join(process.cwd(), 'src', 'views', 'emails');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async loadTemplate(templateName) {
|
|
15
|
+
try {
|
|
16
|
+
const templatePath = path.join(this.templatesPath, `${templateName}.html`);
|
|
17
|
+
return fs.readFileSync(templatePath, 'utf8');
|
|
18
|
+
} catch (error) {
|
|
19
|
+
logger.error(`Failed to load email template: ${templateName}`, error);
|
|
20
|
+
throw new Error(`Email template ${templateName} not found`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
replaceVariables(template, variables) {
|
|
25
|
+
let processedTemplate = template;
|
|
26
|
+
|
|
27
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
28
|
+
const regex = new RegExp(`{{${key}}}`, 'g');
|
|
29
|
+
processedTemplate = processedTemplate.replace(regex, value || '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return processedTemplate;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async sendEmail({ to, subject, template, variables = {}, attachments = [] }) {
|
|
36
|
+
try {
|
|
37
|
+
if (!process.env.RESEND_API_KEY) {
|
|
38
|
+
logger.warn('RESEND_API_KEY not configured. Email would be sent in production.');
|
|
39
|
+
logger.info(`Email would be sent to: ${to}, Subject: ${subject}`);
|
|
40
|
+
return { success: true, messageId: 'dev-mode' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const htmlTemplate = await this.loadTemplate(template);
|
|
44
|
+
const html = this.replaceVariables(htmlTemplate, {
|
|
45
|
+
...variables,
|
|
46
|
+
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
|
|
47
|
+
backendUrl: process.env.BACKEND_URL || 'http://localhost:3001',
|
|
48
|
+
year: new Date().getFullYear(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const emailData = {
|
|
52
|
+
from: this.fromEmail,
|
|
53
|
+
to: Array.isArray(to) ? to : [to],
|
|
54
|
+
subject,
|
|
55
|
+
html,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (attachments.length > 0) {
|
|
59
|
+
emailData.attachments = attachments;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const result = await resend.emails.send(emailData);
|
|
63
|
+
|
|
64
|
+
logger.info(`Email sent successfully to ${to}: ${subject}`);
|
|
65
|
+
return { success: true, messageId: result.id };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
logger.error('Failed to send email:', error);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async sendVerificationEmail(to, token, firstName) {
|
|
73
|
+
const verificationUrl = `${process.env.FRONTEND_URL}/verify-email/${token}`;
|
|
74
|
+
|
|
75
|
+
return this.sendEmail({
|
|
76
|
+
to,
|
|
77
|
+
subject: 'Verify your email address',
|
|
78
|
+
template: 'verification',
|
|
79
|
+
variables: {
|
|
80
|
+
firstName,
|
|
81
|
+
verificationUrl,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async sendWelcomeEmail(to, firstName) {
|
|
87
|
+
return this.sendEmail({
|
|
88
|
+
to,
|
|
89
|
+
subject: 'Welcome to Arktos!',
|
|
90
|
+
template: 'welcome',
|
|
91
|
+
variables: {
|
|
92
|
+
firstName,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async sendPasswordResetEmail(to, token, firstName) {
|
|
98
|
+
const resetUrl = `${process.env.FRONTEND_URL}/reset-password/${token}`;
|
|
99
|
+
|
|
100
|
+
return this.sendEmail({
|
|
101
|
+
to,
|
|
102
|
+
subject: 'Reset your password',
|
|
103
|
+
template: 'resetPassword',
|
|
104
|
+
variables: {
|
|
105
|
+
firstName,
|
|
106
|
+
resetUrl,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async sendNotificationEmail(to, subject, message, firstName) {
|
|
112
|
+
return this.sendEmail({
|
|
113
|
+
to,
|
|
114
|
+
subject,
|
|
115
|
+
template: 'notification',
|
|
116
|
+
variables: {
|
|
117
|
+
firstName,
|
|
118
|
+
message,
|
|
119
|
+
subject,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Utility method to validate email addresses
|
|
125
|
+
validateEmail(email) {
|
|
126
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
127
|
+
return emailRegex.test(email);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Bulk email sending (for newsletters, announcements, etc.)
|
|
131
|
+
async sendBulkEmail({ recipients, subject, template, variables = {} }) {
|
|
132
|
+
const results = [];
|
|
133
|
+
|
|
134
|
+
for (const recipient of recipients) {
|
|
135
|
+
try {
|
|
136
|
+
const result = await this.sendEmail({
|
|
137
|
+
to: recipient.email,
|
|
138
|
+
subject,
|
|
139
|
+
template,
|
|
140
|
+
variables: {
|
|
141
|
+
...variables,
|
|
142
|
+
firstName: recipient.firstName || '',
|
|
143
|
+
lastName: recipient.lastName || '',
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
results.push({ email: recipient.email, success: true, messageId: result.messageId });
|
|
148
|
+
|
|
149
|
+
// Add small delay to avoid rate limiting
|
|
150
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
151
|
+
} catch (error) {
|
|
152
|
+
logger.error(`Failed to send bulk email to ${recipient.email}:`, error);
|
|
153
|
+
results.push({ email: recipient.email, success: false, error: error.message });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return results;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export default new EmailService();
|