Skip to content

Commit 4263fa4

Browse files
authored
Merge pull request #185 from guidefari/migration/6-email-group
feat(email): port email vertical slice
2 parents 97f375f + d9bca5a commit 4263fa4

14 files changed

Lines changed: 566 additions & 435 deletions

apps/vps/src/app.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Duration, Effect, Schedule } from 'effect'
22
import configureOpenAPI from '@/lib/configure-open-api'
33
import { createAppEffect } from '@/lib/create-app'
4-
import email from '@/routes/email/email.index'
54
import musicReminders from '@/routes/music-reminders/music-reminders.index'
65
import { seoRouter, shareRouter } from '@/routes/redirect/redirect.index'
76
import rss from '@/routes/rss/rss.index'
@@ -19,7 +18,6 @@ const setupRoutesEffect = Effect.gen(function* () {
1918

2019
configureOpenAPI(app)
2120

22-
app.route('/api/email', email)
2321
app.route('/api/upload', upload)
2422
app.route('/api/upload', uploadMultipart)
2523
app.route('/api/music-reminders', musicReminders)
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { Api } from '@gbfm/api/api'
2+
import { type SendMixNotificationInput, type SendMixNotificationResponse } from '@gbfm/api/email'
3+
import { AuthSession } from '@gbfm/api/middleware/auth'
4+
import { EMAIL_DELIVERY_STATUSES, type EmailDeliveryStatus } from '@gbfm/core/status'
5+
import { sendMixNotificationEmail } from '@gbfm/email/sender'
6+
import { and, eq } from 'drizzle-orm'
7+
import { Effect, Schema } from 'effect'
8+
import { HttpApiBuilder, HttpApiError } from 'effect/unstable/httpapi'
9+
import { db } from '@/db'
10+
import { audioTable } from '@/db/audio.schema'
11+
import { user as usersTable } from '@/db/auth.schema'
12+
import {
13+
EMAIL_NOTIFICATION_TYPES,
14+
type EmailNotificationType,
15+
type SelectEmailDeliveryLog
16+
} from '@/db/email.schema'
17+
import { DatabaseError, getErrorMessage } from '@/errors'
18+
import { dieOnDatabaseError as makeDieOnDatabaseError } from '@/http/handler-utils'
19+
import {
20+
createEmailDeliveryLog,
21+
getAdminEmailLogs,
22+
markEmailDeliveryLogAsFailed,
23+
markEmailDeliveryLogAsSent
24+
} from '@/repositories/email-delivery-log.repository'
25+
import {
26+
canReceiveEmail,
27+
getActiveMixRecipients
28+
} from '@/repositories/email-preferences.repository'
29+
import { runAppFork } from '@/runtime'
30+
31+
const dieOnDatabaseError = makeDieOnDatabaseError('email')
32+
const EmailMetadata = Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown))
33+
34+
const EMAIL_TYPE_NORMALIZATION_MAP: Record<string, EmailNotificationType> = {
35+
TRANSACTIONAL: EMAIL_NOTIFICATION_TYPES.TRANSACTIONAL,
36+
MIX_RELEASE: EMAIL_NOTIFICATION_TYPES.MIX_RELEASE,
37+
MIXRELEASE: EMAIL_NOTIFICATION_TYPES.MIX_RELEASE,
38+
MIX_NOTIFICATION: EMAIL_NOTIFICATION_TYPES.MIX_RELEASE,
39+
MUSIC_REMINDER: EMAIL_NOTIFICATION_TYPES.MIX_RELEASE,
40+
PROMOTIONAL: EMAIL_NOTIFICATION_TYPES.PROMOTIONAL,
41+
SYSTEM: EMAIL_NOTIFICATION_TYPES.SYSTEM
42+
}
43+
44+
const EMAIL_STATUS_NORMALIZATION_MAP: Record<string, EmailDeliveryStatus> = {
45+
PENDING: EMAIL_DELIVERY_STATUSES.PENDING,
46+
SENT: EMAIL_DELIVERY_STATUSES.SENT,
47+
DELIVERED: EMAIL_DELIVERY_STATUSES.DELIVERED,
48+
BOUNCED: EMAIL_DELIVERY_STATUSES.BOUNCED,
49+
COMPLAINED: EMAIL_DELIVERY_STATUSES.COMPLAINED,
50+
FAILED: EMAIL_DELIVERY_STATUSES.FAILED,
51+
SUCCESS: EMAIL_DELIVERY_STATUSES.SENT,
52+
FAILURE: EMAIL_DELIVERY_STATUSES.FAILED
53+
}
54+
55+
class MixNotFoundError extends Error {
56+
readonly _tag = 'MixNotFoundError'
57+
}
58+
59+
function normalizeLogToken(value: string | null | undefined) {
60+
return (value ?? '')
61+
.trim()
62+
.toUpperCase()
63+
.replace(/[^A-Z0-9]+/g, '_')
64+
.replace(/^_+|_+$/g, '')
65+
}
66+
67+
function toEmailLogResponse(log: SelectEmailDeliveryLog) {
68+
const normalizedTypeToken = normalizeLogToken(log.emailType)
69+
const normalizedStatusToken = normalizeLogToken(log.status)
70+
71+
return {
72+
...log,
73+
emailType: EMAIL_TYPE_NORMALIZATION_MAP[normalizedTypeToken] ?? EMAIL_NOTIFICATION_TYPES.SYSTEM,
74+
status: EMAIL_STATUS_NORMALIZATION_MAP[normalizedStatusToken] ?? EMAIL_DELIVERY_STATUSES.FAILED,
75+
metadata: Schema.decodeUnknownSync(EmailMetadata)(log.metadata),
76+
sentAt: log.sentAt?.toISOString() ?? null,
77+
deliveredAt: log.deliveredAt?.toISOString() ?? null,
78+
bouncedAt: log.bouncedAt?.toISOString() ?? null,
79+
complainedAt: log.complainedAt?.toISOString() ?? null,
80+
createdAt: log.createdAt.toISOString(),
81+
updatedAt: log.updatedAt.toISOString()
82+
}
83+
}
84+
85+
async function sendMixNotification(
86+
input: SendMixNotificationInput
87+
): Promise<SendMixNotificationResponse> {
88+
const recipients =
89+
input.recipients && input.recipients.length > 0
90+
? input.recipients
91+
: await getActiveMixRecipients()
92+
93+
if (recipients.length === 0) {
94+
return { success: true, sentTo: [], emailIds: [], message: 'No opted-in recipients' }
95+
}
96+
97+
const [mix] = await db
98+
.select()
99+
.from(audioTable)
100+
.where(and(eq(audioTable.slug, input.mixSlug), eq(audioTable.type, 'mix')))
101+
.limit(1)
102+
103+
if (!mix) {
104+
throw new MixNotFoundError(`Mix not found: ${input.mixSlug}`)
105+
}
106+
107+
const mixUrl = `https://goosebumps.fm/mixes/${mix.slug}`
108+
const coverImageUrl = input.metadata?.coverImageUrl || mix.thumbnailUrl || undefined
109+
const releaseDate =
110+
input.metadata?.releaseDate ||
111+
(mix.createdAt
112+
? new Date(mix.createdAt).toLocaleDateString('en-US', {
113+
year: 'numeric',
114+
month: 'long',
115+
day: 'numeric'
116+
})
117+
: new Date().toLocaleDateString('en-US', {
118+
year: 'numeric',
119+
month: 'long',
120+
day: 'numeric'
121+
}))
122+
123+
const sentTo: string[] = []
124+
const skipped: string[] = []
125+
const errors: string[] = []
126+
const emailIds: string[] = []
127+
128+
for (const recipient of recipients) {
129+
const [user] = await db
130+
.select()
131+
.from(usersTable)
132+
.where(eq(usersTable.email, recipient))
133+
.limit(1)
134+
135+
const username = user?.name || input.metadata?.username || recipient.split('@')[0] || 'listener'
136+
137+
if (user && !(await canReceiveEmail(user.id, EMAIL_NOTIFICATION_TYPES.MIX_RELEASE))) {
138+
Effect.annotateCurrentSpan('totalRecipients', recipients.length).pipe(runAppFork)
139+
Effect.annotateCurrentSpan('mixSlug', input.mixSlug).pipe(runAppFork)
140+
Effect.annotateCurrentSpan('mixTitle', input.metadata?.mixTitle || mix.title).pipe(runAppFork)
141+
Effect.logInfo('[Email] Sending mix notification emails', {
142+
totalRecipients: recipients.length,
143+
mixSlug: input.mixSlug,
144+
mixTitle: input.metadata?.mixTitle || mix.title
145+
}).pipe(runAppFork)
146+
skipped.push(recipient)
147+
continue
148+
}
149+
150+
const mixTitle = input.metadata?.mixTitle || mix.title
151+
const deliveryLog = await createEmailDeliveryLog({
152+
userId: user?.id,
153+
recipientEmail: recipient,
154+
recipientName: username,
155+
emailType: EMAIL_NOTIFICATION_TYPES.MIX_RELEASE,
156+
templateName: 'mix-notification',
157+
subject: `New mix: ${mixTitle}`,
158+
status: EMAIL_DELIVERY_STATUSES.PENDING,
159+
metadata: {
160+
mixId: mix.id,
161+
mixSlug: mix.slug,
162+
mixTitle,
163+
artistName: input.metadata?.artistName || 'Guide Fari',
164+
coverImageUrl,
165+
releaseDate
166+
}
167+
})
168+
169+
try {
170+
await sendMixNotificationEmail({
171+
to: recipient,
172+
username,
173+
mixTitle,
174+
artistName: input.metadata?.artistName || 'Guide Fari',
175+
mixUrl,
176+
coverImageUrl,
177+
releaseDate
178+
})
179+
await markEmailDeliveryLogAsSent(deliveryLog.id)
180+
sentTo.push(recipient)
181+
emailIds.push(deliveryLog.id)
182+
} catch (error: unknown) {
183+
Effect.logError('[Email] Failed to send mix notification email', {
184+
recipient,
185+
userId: user?.id,
186+
mixSlug: input.mixSlug,
187+
mixTitle: input.metadata?.mixTitle || mix.title,
188+
emailLogId: deliveryLog.id,
189+
error: getErrorMessage(error)
190+
}).pipe(runAppFork)
191+
await markEmailDeliveryLogAsFailed(deliveryLog.id, getErrorMessage(error))
192+
errors.push(recipient)
193+
}
194+
}
195+
196+
if (sentTo.length === 0 && skipped.length === 0) {
197+
throw new Error('Failed to send any emails')
198+
}
199+
200+
return {
201+
success: true,
202+
sentTo,
203+
emailIds,
204+
message: `Successfully sent ${sentTo.length} notification(s)${
205+
skipped.length > 0 ? ` (${skipped.length} skipped due to preferences)` : ''
206+
}${errors.length > 0 ? ` (${errors.length} failed)` : ''}`
207+
}
208+
}
209+
210+
const requireAdmin = Effect.gen(function* () {
211+
const { user } = yield* AuthSession
212+
if (user.role !== 'admin') {
213+
return yield* new HttpApiError.Forbidden()
214+
}
215+
})
216+
217+
export const EmailHandlersLive = HttpApiBuilder.group(Api, 'email', (handlers) =>
218+
handlers
219+
.handle('sendMixNotification', ({ payload }) =>
220+
Effect.gen(function* () {
221+
yield* requireAdmin
222+
return yield* Effect.tryPromise({
223+
try: () => sendMixNotification(payload),
224+
catch: (error: unknown) =>
225+
error instanceof MixNotFoundError
226+
? new HttpApiError.NotFound()
227+
: new DatabaseError({
228+
message: `Failed to send mix notification emails: ${getErrorMessage(error)}`,
229+
operation: 'send',
230+
table: 'email_delivery_logs'
231+
})
232+
}).pipe(dieOnDatabaseError)
233+
})
234+
)
235+
.handle('getEmailLogs', ({ query }) =>
236+
Effect.gen(function* () {
237+
yield* requireAdmin
238+
const result = yield* Effect.tryPromise({
239+
try: () => getAdminEmailLogs(query),
240+
catch: (error: unknown) =>
241+
new DatabaseError({
242+
message: `Failed to fetch email logs: ${getErrorMessage(error)}`,
243+
operation: 'select',
244+
table: 'email_delivery_logs'
245+
})
246+
}).pipe(dieOnDatabaseError)
247+
248+
return {
249+
data: result.data.map(toEmailLogResponse),
250+
pagination: result.pagination
251+
}
252+
})
253+
)
254+
)

apps/vps/src/http/routes.blackbox.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,26 @@ describe('admin (HttpApiBuilder group, Step 6)', () => {
278278
})
279279
})
280280

281+
describe('email (HttpApiBuilder group, Step 6)', () => {
282+
it('GET /api/email/logs returns 401 without a session cookie', async () => {
283+
const res = await webHandler.handler(new Request('http://localhost/api/email/logs'))
284+
285+
expect(res.status).toBe(401)
286+
})
287+
288+
it('POST /api/email/send-mix-notification returns 401 without a session cookie', async () => {
289+
const res = await webHandler.handler(
290+
new Request('http://localhost/api/email/send-mix-notification', {
291+
method: 'POST',
292+
headers: { 'content-type': 'application/json' },
293+
body: JSON.stringify({ mixSlug: 'test-mix' })
294+
})
295+
)
296+
297+
expect(res.status).toBe(401)
298+
})
299+
})
300+
281301
describe('invite (HttpApiBuilder group, Step 6)', () => {
282302
it('POST /api/invite/send returns 401 without a session cookie', async () => {
283303
const res = await webHandler.handler(

apps/vps/src/http/routes.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { HttpApiBuilder } from 'effect/unstable/httpapi'
66
import type { AppType } from '@/app'
77
import { AdminHandlersLive } from '@/http/admin.handlers'
88
import { AudioHandlersLive } from '@/http/audio.handlers'
9+
import { EmailHandlersLive } from '@/http/email.handlers'
910
import { FavoritesHandlersLive } from '@/http/favorites.handlers'
1011
import { FileManagerHandlersLive } from '@/http/file-manager.handlers'
1112
import { checkDatabase, makeHealthHandlers } from '@/http/health.handlers'
@@ -78,8 +79,8 @@ export const createWebHandler = (
7879
Layer.provide(ReleaseHandlersLive),
7980
Layer.provide(PostHandlersLive),
8081
Layer.provide(AudioHandlersLive),
81-
Layer.provide(FavoritesHandlersLive),
82-
Layer.provide(NewsletterHandlersLive),
82+
Layer.provide(EmailHandlersLive),
83+
Layer.provide(Layer.mergeAll(FavoritesHandlersLive, NewsletterHandlersLive)),
8384
Layer.provide(FileManagerHandlersLive),
8485
Layer.provide(SpotifyHandlersLive),
8586
Layer.provide(ShowsHandlersLive),

0 commit comments

Comments
 (0)