Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
RESEND_API_KEY='your_resend_api_key'
EMAIL_DOMAIN='delivered@resend.dev'
RESEND_WAITLIST_AUDIENCE_ID="your_waitlist_audience_id"

DATABASE_URL='postgres://postgres:your_secure_password@joysticked-postgres:5432/joysticked'

Expand Down
2 changes: 1 addition & 1 deletion apps/api/drizzle/meta/20251111181257_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@
"schemas": {},
"tables": {}
}
}
}
2 changes: 1 addition & 1 deletion apps/api/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
"breakpoints": true
}
]
}
}
5 changes: 4 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@
"build": "bun build --compile ./src/shared/http/index.ts --minify-whitespace --minify-syntax --outfile ./server"
},
"dependencies": {
"@bogeychan/elysia-logger": "^0.1.10",
"@elysiajs/cors": "^1.4.0",
"@elysiajs/openapi": "1.4.11",
"@react-email/components": "1.0.0",
"@react-email/render": "2.0.0",
"@types/react": "19.2.2",
"bunlimit": "^0.1.4",
"drizzle-orm": "0.44.7",
"elysia": "1.4.15",
"elysia": "1.4.13",
"postgres": "3.4.7",
"react": "19.2.0",
"react-dom": "19.2.0",
"resend": "6.4.2",
"zod": "4.1.12"
},
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/modules/waitlist/get-count/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Elysia } from 'elysia';

import { databaseMiddleware } from '../../../shared/http/middlewares/database';
import { getWaitlistCountUseCase } from './use-case';

export const getWaitlistRouter = new Elysia()
.use(databaseMiddleware)
.get('/', async ({ db, status }) => {
const count = await getWaitlistCountUseCase(db);

return status(200, { count });
});
5 changes: 5 additions & 0 deletions apps/api/src/modules/waitlist/get-count/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { z } from 'zod';

export const getWaitlistCountSuccessReponseSchema = z.object({
count: z.number().int()
});
8 changes: 8 additions & 0 deletions apps/api/src/modules/waitlist/get-count/use-case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Database } from '../../../shared/database';
import { createWaitListRepository } from '../../../shared/database/repositories/waitlist-repository';

export async function getWaitlistCountUseCase(db: Database) {
const waitlistRepository = createWaitListRepository(db);

return await waitlistRepository.getCount();
}
24 changes: 24 additions & 0 deletions apps/api/src/modules/waitlist/get-waitlist-join-date/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Elysia } from 'elysia';
import z from 'zod';

import { databaseMiddleware } from '../../../shared/http/middlewares/database';
import { zDate } from '../../../shared/schemas/zod-date';
import { getWaitlistJoinDateQuerySchema } from './schemas';
import { getWaitlistJoinDateUseCase } from './use-case';

export const getWaitlistJoinDateRouter = new Elysia().use(databaseMiddleware).get(
'/join-date',
async ({ db, status, query }) => {
const joinDate = await getWaitlistJoinDateUseCase(db, {
email: query.email
});

return status(200, { joinDate });
},
{
query: getWaitlistJoinDateQuerySchema,
response: {
200: z.object({ joinDate: zDate })
}
}
);
12 changes: 12 additions & 0 deletions apps/api/src/modules/waitlist/get-waitlist-join-date/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { z } from 'zod';
import { zDate } from '../../../shared/schemas/zod-date';

export const getWaitlistJoinDateQuerySchema = z.object({
email: z.email()
});

export type GetWaitlistJoinDate = z.infer<typeof getWaitlistJoinDateQuerySchema>;

export const getWaitlistJoinDateResponseSchema = z.object({
joinDate: zDate
});
16 changes: 16 additions & 0 deletions apps/api/src/modules/waitlist/get-waitlist-join-date/use-case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Database } from '../../../shared/database';
import { createWaitListRepository } from '../../../shared/database/repositories/waitlist-repository';
import { ResourceNotFoundError } from '../../../shared/errors/resource-not-found-error';
import type { GetWaitlistJoinDate } from './schemas';

export async function getWaitlistJoinDateUseCase(db: Database, data: GetWaitlistJoinDate) {
const waitlistRepository = createWaitListRepository(db);

const waitlistEntry = await waitlistRepository.findByEmail(data.email);

if (!waitlistEntry) {
throw new ResourceNotFoundError('Waitlist entry not found');
}

return waitlistEntry.joinedAt;
}
1 change: 1 addition & 0 deletions apps/api/src/modules/waitlist/join-waitlist/router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Elysia } from 'elysia';

import { databaseMiddleware } from '../../../shared/http/middlewares/database';
import { joinWaitlistBodySchema, joinWaitlistSuccessResponseSchema } from './schemas';
import { joinWaitlistUseCase } from './use-case';
Expand Down
14 changes: 7 additions & 7 deletions apps/api/src/modules/waitlist/join-waitlist/use-case.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
import { envs } from '../../../shared/config/envs';
import type { Database } from '../../../shared/database';
import { createWaitListRepository } from '../../../shared/database/repositories/waitlist-repository';
import { executeTransaction } from '../../../shared/database/transaction';
import { ConflictError } from '../../../shared/errors/conflict-error';
import { InternalServerError } from '../../../shared/errors/internal-server-error';
import { emailService } from '../../../shared/providers/emails';

export async function joinWaitlistUseCase(db: Database, { email }: { email: string }) {
const waitlistRepository = createWaitListRepository(db);

const existingEntry = await waitlistRepository.findEntryByEmail(email);
const existingEntry = await waitlistRepository.findByEmail(email);

if (existingEntry) {
throw new ConflictError('Email already exists in waitlist');
}

return executeTransaction(db, async (tx) => {
const entry = await waitlistRepository.createEntry(email, tx);
const entry = await waitlistRepository.create(email, tx);

const { error } = await emailService.sendEmail({
await emailService.sendEmailAndAddToAudience({
to: email,
template: 'waitlist-welcome'
template: 'waitlist-welcome',
audienceId: envs.services.RESEND_WAITLIST_AUDIENCE_ID
});

if (error) throw new InternalServerError('Failed to send email');

return { entry };
});
}
9 changes: 8 additions & 1 deletion apps/api/src/modules/waitlist/router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Elysia } from 'elysia';

import { getWaitlistRouter } from './get-count/router';
import { getWaitlistJoinDateRouter } from './get-waitlist-join-date/router';
import { joinWaitlistRouter } from './join-waitlist/router';
import { unsubscribeWaitlistRouter } from './unsubscribe-waitlist/router';

export const waitlistRouter = new Elysia({ prefix: '/waitlist', tags: ['waitlist'] }).use([
joinWaitlistRouter
joinWaitlistRouter,
getWaitlistRouter,
getWaitlistJoinDateRouter,
unsubscribeWaitlistRouter
]);
16 changes: 16 additions & 0 deletions apps/api/src/modules/waitlist/unsubscribe-waitlist/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Elysia } from 'elysia';
import z from 'zod';
import { databaseMiddleware } from '../../../shared/http/middlewares/database';
import { unsubscribeFromWaitlistUseCase } from './use-case';

export const unsubscribeWaitlistRouter = new Elysia().use(databaseMiddleware).post(
'/unsubscribe',
async ({ body, db, status }) => {
await unsubscribeFromWaitlistUseCase(db, { email: body.email });

return status(204);
},
{
body: z.object({ email: z.email() })
}
);
7 changes: 7 additions & 0 deletions apps/api/src/modules/waitlist/unsubscribe-waitlist/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from 'zod';

export const unsubscribeFromWaitlistSchema = z.object({
email: z.email()
});

export type UnsubscribeFromWaitlist = z.infer<typeof unsubscribeFromWaitlistSchema>;
19 changes: 19 additions & 0 deletions apps/api/src/modules/waitlist/unsubscribe-waitlist/use-case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { envs } from '../../../shared/config/envs';
import type { Database } from '../../../shared/database';
import { createWaitListRepository } from '../../../shared/database/repositories/waitlist-repository';
import { executeTransaction } from '../../../shared/database/transaction';
import { emailService } from '../../../shared/providers/emails';
import type { UnsubscribeFromWaitlist } from './schemas';

export async function unsubscribeFromWaitlistUseCase(db: Database, data: UnsubscribeFromWaitlist) {
const waitlistRepository = createWaitListRepository(db);

return executeTransaction(db, async (tx) => {
await waitlistRepository.deleteByEmail(data.email, tx);

await emailService.removeFromAudience({
email: data.email,
audienceId: envs.services.RESEND_WAITLIST_AUDIENCE_ID
});
});
}
1 change: 1 addition & 0 deletions apps/api/src/shared/config/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function loadDbEnvs() {
function loadServicesEnvs() {
const schema = z.object({
RESEND_API_KEY: z.string(),
RESEND_WAITLIST_AUDIENCE_ID: z.string(),
EMAIL_DOMAIN: z.string()
});

Expand Down
18 changes: 12 additions & 6 deletions apps/api/src/shared/database/repositories/waitlist-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,40 @@ import type { Transaction } from '../transaction';
class WaitListRepository {
constructor(private readonly db: Database) {}

async findEntryByEmail(email: string) {
async findByEmail(email: string) {
const opt = await this.db.select().from(waitlists).where(eq(waitlists.email, email));

if (!opt[0]) return null;

return opt[0];
}

async createEntry(email: string, tx?: Transaction) {
async create(email: string, tx?: Transaction) {
const entry = await (tx ?? this.db).insert(waitlists).values({ email }).returning();

if (!entry[0]) throw new InternalServerError('Failed to create waitlist entry');

return entry[0];
}

async deleteEntry(id: number) {
async delete(id: number) {
await this.db.delete(waitlists).where(eq(waitlists.id, id));
}

async getAllEntries() {
async deleteByEmail(email: string, tx?: Transaction) {
await (tx ?? this.db).delete(waitlists).where(eq(waitlists.email, email));
}

async getAll() {
const entries = await this.db.select().from(waitlists).orderBy(desc(waitlists.joinedAt));

return entries;
}

async getEntriesCount() {
const count = await this.db.select({ count: sql<number>`count(*)` }).from(waitlists);
async getCount() {
const count = await this.db
.select({ count: sql<number>`count(*)`.mapWith(Number) })
.from(waitlists);

return count[0].count;
}
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/shared/errors/resource-not-found-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class ResourceNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'ResourceNotFoundError';
}
}
8 changes: 8 additions & 0 deletions apps/api/src/shared/http/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { logger } from '@bogeychan/elysia-logger';
import cors from '@elysiajs/cors';
import openapi from '@elysiajs/openapi';
import { Elysia } from 'elysia';
import { z } from 'zod';
Expand All @@ -8,6 +10,12 @@ import { errorHandler } from './middlewares/error-handler';
import { rateLimitMiddleware } from './middlewares/rate-limitter';

const app = new Elysia()
.use(cors())
.use(
logger({
level: 'info'
})
)
.use(errorHandler)
.use(rateLimitMiddleware)
.use(
Expand Down
49 changes: 43 additions & 6 deletions apps/api/src/shared/http/middlewares/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { Elysia } from 'elysia';
import { ConflictError } from '../../errors/conflict-error';
import { InternalServerError } from '../../errors/internal-server-error';
import { RateLimitError } from '../../errors/rate-limit-error';
import { ResourceNotFoundError } from '../../errors/resource-not-found-error';

export const errorHandler = new Elysia({ name: 'error-handler' })
.error({
CONFLICT: ConflictError,
INTERNAL_SERVER_ERROR: InternalServerError,
RATE_LIMIT_EXCEEDED: RateLimitError
RATE_LIMIT_EXCEEDED: RateLimitError,
RESOURCE_NOT_FOUND: ResourceNotFoundError
})
.onError({ as: 'scoped' }, ({ error, code, status }) => {
switch (code) {
Expand All @@ -26,23 +28,58 @@ export const errorHandler = new Elysia({ name: 'error-handler' })
message: error.message
});
}
case 'RESOURCE_NOT_FOUND': {
return status(404, {
code,
message: error.message
});
}
case 'VALIDATION': {
const validationErrors: Record<string, string> = {};

// Parse the error message if it's a stringified JSON
type ValidationErrorDetails = {
errors?: Array<{
path?: string[];
message: string;
}>;
message?: string;
};

let errorDetails: ValidationErrorDetails;
try {
errorDetails = typeof error.message === 'string' ? JSON.parse(error.message) : error;
} catch {
errorDetails = error;
}

// Handle ElysiaJS validation errors with array of errors
if (errorDetails?.errors && Array.isArray(errorDetails.errors)) {
for (const validationError of errorDetails.errors) {
if (validationError.path && Array.isArray(validationError.path)) {
const fieldPath = validationError.path.join('.');
validationErrors[fieldPath] = validationError.message;
}
}
}

// Handle single validator error
if (error.validator && 'path' in error.validator) {
const path = error.validator.path.replace(/^\//, '');
validationErrors[path] = error.validator.message;
} else {
validationErrors.general = error.message;
}

console.log(error);
// Fallback to general error if no specific errors were found
if (Object.keys(validationErrors).length === 0) {
validationErrors.general = errorDetails?.message || error.message;
}

console.error('Validation error:', errorDetails);

return status(422, {
code,
message: 'Validation Failed',
error: validationErrors,
status: error.status
errors: validationErrors
});
}
case 'NOT_FOUND': {
Expand Down
Loading
Loading