Skip to content
Open
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
5 changes: 4 additions & 1 deletion .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
{
"name": "backend",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "docker compose -f apps/backend/docker-compose.yml up -d && pnpm --filter plotwist-api run dev"],
"runtimeArgs": [
"-c",
"docker compose -f apps/backend/docker-compose.yml up -d && pnpm --filter plotwist-api run dev"
],
"port": 3333
}
]
Expand Down
9 changes: 7 additions & 2 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
"Bash(source ~/.zshrc)",
"Bash(npx @biomejs/biome check --changed)",
"Bash(npx @biomejs/biome check src/config.ts src/infra/adapters/open-ai.ts src/domain/services/user-stats/get-user-ai-recommendations.ts src/domain/services/user-stats/get-user-ai-recommendations.spec.ts)",
"Bash(pnpm run:*)"
"Bash(pnpm run:*)",
"Bash(make run:*)",
"Bash(curl -s http://localhost:3333/healthcheck)",
"Bash(make test:*)"
]
}
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": ["grafana"]
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ coverage
out/
build
dist
*.tsbuildinfo


# Debug
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FastifyRedis } from '@fastify/redis'
import type { Language } from '@plotwist_app/tmdb'
import { selectReceivedRecommendations } from '@/infra/db/repositories/recommendations-repository'
import { getTMDBDataService } from '@/domain/services/tmdb/get-tmdb-data'
import { selectReceivedRecommendations } from '@/infra/db/repositories/recommendations-repository'

export async function getReceivedRecommendationsService(
userId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export type SendRecommendationInput = {
message: string | null
}

export async function sendRecommendationService(input: SendRecommendationInput) {
export async function sendRecommendationService(
input: SendRecommendationInput
) {
const recommendation = await insertRecommendation(input)
return { recommendation }
}
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,6 @@ describe('get user ai recommendations', () => {
mediaType: 'TV_SHOW',
})
}

// Cold start path is triggered when candidate pool < 3 (1 movie + 1 TV = 2)
// Mock search.multi to resolve both titles correctly
;(tmdb.search.multi as Mock).mockImplementation((title: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,9 @@ async function detectIsAnimeFanFromWatched(
tvSeeds.map(async item => {
try {
const details = await tmdb.tv.details(item.tmdbId, language)
return (
(details as { genres?: { id: number }[] }).genres ?? []
).some(g => g.id === ANIME_GENRE_ID)
return ((details as { genres?: { id: number }[] }).genres ?? []).some(
g => g.id === ANIME_GENRE_ID
)
} catch {
return false
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { faker } from '@faker-js/faker'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { makeUser } from '@/test/factories/make-user'
import { requestPasswordResetService } from './request-password-reset'
import * as sendPasswordResetEmail from './send-password-reset-email'

describe('requestPasswordReset', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('should return generic response and send email when user exists', async () => {
const user = await makeUser()
const sendEmailSpy = vi
.spyOn(sendPasswordResetEmail, 'sendPasswordResetEmailService')
.mockResolvedValueOnce(undefined)

const result = await requestPasswordResetService({ login: user.email })

expect(result).toEqual({ status: 'password_reset_email_sent' })
expect(sendEmailSpy).toHaveBeenCalledOnce()
expect(sendEmailSpy).toHaveBeenCalledWith(
expect.objectContaining({ email: user.email })
)
})

it('should return same generic response when user does not exist', async () => {
const sendEmailSpy = vi.spyOn(
sendPasswordResetEmail,
'sendPasswordResetEmailService'
)

const result = await requestPasswordResetService({
login: faker.internet.email(),
})

expect(result).toEqual({ status: 'password_reset_email_sent' })
expect(sendEmailSpy).not.toHaveBeenCalled()
})
})
20 changes: 20 additions & 0 deletions apps/backend/src/domain/services/users/request-password-reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { generateMagicLinkTokenService } from '@/domain/services/magic-link/generate-magic-link'
import { findUserByEmailOrUsername } from '@/infra/db/repositories/login-repository'
import { sendPasswordResetEmailService } from './send-password-reset-email'

type RequestPasswordResetInput = {
login: string
}

export async function requestPasswordResetService({
login,
}: RequestPasswordResetInput) {
const user = await findUserByEmailOrUsername(login)

if (user) {
const { token } = await generateMagicLinkTokenService(user.id)
await sendPasswordResetEmailService({ email: user.email, token })
}

return { status: 'password_reset_email_sent' as const }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { config } from '@/config'
import type { EmailMessage } from '@/domain/entities/email-message'
import { emailServiceFactory } from '@/infra/factories/resend-factory'

type SendPasswordResetEmailServiceInput = {
email: string
token: string
}

export async function sendPasswordResetEmailService({
email,
token,
}: SendPasswordResetEmailServiceInput) {
const link = `${config.app.CLIENT_URL}/reset-password?token=${token}`

const html = `Click the link to reset your password: <a href="${link}">Reset password</a>`
const subject = 'Reset your Plotwist password'

const emailService = emailServiceFactory('Resend')

const emailMessage: EmailMessage = {
to: [email],
subject,
html,
}

await emailService.sendEmail(emailMessage)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { faker } from '@faker-js/faker'
import { describe, expect, it } from 'vitest'
import { InvalidTokenError } from '@/domain/errors/invalid-token-error'
import { makeMagicToken } from '@/test/factories/make-magic-token'
import { makeUser } from '@/test/factories/make-user'
import { updatePasswordService } from './update-user-password'

describe('updateUserPassword', () => {
it('should update the password with a valid token', async () => {
const user = await makeUser()
const { token } = await makeMagicToken(user.id)
const newPassword = faker.internet.password()

const result = await updatePasswordService({ password: newPassword, token })

expect(result).toEqual({ status: 'password_set' })
})

it('should return InvalidTokenError for a non-existent token', async () => {
const result = await updatePasswordService({
password: faker.internet.password(),
token: 'non-existent-token',
})

expect(result).toBeInstanceOf(InvalidTokenError)
})

it('should return InvalidTokenError for an already-used token', async () => {
const user = await makeUser()
const { token } = await makeMagicToken(user.id, { used: true })

const result = await updatePasswordService({
password: faker.internet.password(),
token,
})

expect(result).toBeInstanceOf(InvalidTokenError)
})

it('should return InvalidTokenError for an expired token', async () => {
const user = await makeUser()
const { token } = await makeMagicToken(user.id, {
expiresAt: new Date(Date.now() - 1000),
})

const result = await updatePasswordService({
password: faker.internet.password(),
token,
})

expect(result).toBeInstanceOf(InvalidTokenError)
})
})
10 changes: 9 additions & 1 deletion apps/backend/src/domain/services/users/update-user-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@ export async function updatePasswordService({
}: UpdatePasswordInput) {
const [tokenRecord] = await selectMagicToken(token)

if (!token) {
if (!tokenRecord) {
return new InvalidTokenError()
}

if (tokenRecord.used) {
return new InvalidTokenError()
}

if (tokenRecord.expiresAt < new Date()) {
return new InvalidTokenError()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,9 @@ export async function selectWatchedItemsWithAvgRating(
tmdbId: schema.userItems.tmdbId,
mediaType: schema.userItems.mediaType,
addedAt: schema.userItems.addedAt,
avgRating: sql<string | null>`AVG(${schema.reviews.rating})::numeric(3,1)::text`,
avgRating: sql<
string | null
>`AVG(${schema.reviews.rating})::numeric(3,1)::text`,
})
.from(schema.userItems)
.leftJoin(
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/infra/db/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import {
uuid,
varchar,
} from 'drizzle-orm/pg-core'
import { userPreferences } from './user-preferences'
import { achievements, userAchievements } from './achievements'
import { userPreferences } from './user-preferences'

export const subscriptionStatusEnum = pgEnum('subscription_status', [
'ACTIVE',
Expand Down Expand Up @@ -776,5 +776,5 @@ export const schema = {
userAchievements,
}

export * from './user-preferences'
export * from './achievements'
export * from './user-preferences'
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { FastifyRedis } from '@fastify/redis'
import type { FastifyReply, FastifyRequest } from 'fastify'
import type { Language } from '@plotwist_app/tmdb'
import { sendRecommendationService } from '@/domain/services/recommendations/send-recommendation'
import type { FastifyReply, FastifyRequest } from 'fastify'
import { getReceivedRecommendationsService } from '@/domain/services/recommendations/get-received-recommendations'
import { respondRecommendationService } from '@/domain/services/recommendations/respond-recommendation'
import { sendRecommendationService } from '@/domain/services/recommendations/send-recommendation'
import {
sendRecommendationBodySchema,
getRecommendationsQuerySchema,
respondRecommendationBodySchema,
respondRecommendationParamsSchema,
sendRecommendationBodySchema,
} from '../schemas/recommendations'

export async function sendRecommendationController(
Expand Down
20 changes: 18 additions & 2 deletions apps/backend/src/infra/http/controllers/user-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getUserById } from '@/domain/services/users/get-by-id'
import { getUserByUsername } from '@/domain/services/users/get-user-by-username'
import { isEmailAvailable } from '@/domain/services/users/is-email-available'
import { checkAvailableUsername } from '@/domain/services/users/is-username-available'
import { requestPasswordResetService } from '@/domain/services/users/request-password-reset'
import { searchUsersByUsername } from '@/domain/services/users/search-users-by-username'
import { updateUserService } from '@/domain/services/users/update-user'
import { updatePasswordService } from '@/domain/services/users/update-user-password'
Expand All @@ -18,6 +19,7 @@ import {
getUserByIdParamsSchema,
getUserByUsernameParamsSchema,
isEmailAvailableQuerySchema,
requestPasswordResetBodySchema,
searchUsersByUsernameQuerySchema,
updateUserBodySchema,
updateUserPasswordBodySchema,
Expand Down Expand Up @@ -138,9 +140,23 @@ export async function updateUserPasswordController(
reply: FastifyReply
) {
const { password, token } = updateUserPasswordBodySchema.parse(request.body)
const { status } = await updatePasswordService({ password, token })
const result = await updatePasswordService({ password, token })

return reply.status(200).send({ status: status })
if (result instanceof DomainError) {
return reply.status(result.status).send({ message: result.message })
}

return reply.status(200).send({ status: result.status })
}

export async function requestPasswordResetController(
request: FastifyRequest,
reply: FastifyReply
) {
const { login } = requestPasswordResetBodySchema.parse(request.body)
const { status } = await requestPasswordResetService({ login })

return reply.status(200).send({ status })
}

export async function updateUserPreferencesController(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import {
toggleFavoriteBodySchema,
getUserFavoritesQuerySchema,
checkFavoriteQuerySchema,
} from '../schemas/user-favorites'
import {
insertFavorite,
deleteFavorite,
selectFavoritesByUser,
insertFavorite,
selectFavorite,
selectFavoritesByUser,
} from '@/infra/db/repositories/user-favorites-repository'
import {
checkFavoriteQuerySchema,
getUserFavoritesQuerySchema,
toggleFavoriteBodySchema,
} from '../schemas/user-favorites'

export async function toggleFavoriteController(
request: FastifyRequest,
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/infra/http/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { config } from '@/config'
import { setInitialCounterValue } from '@/domain/services/shared-urls/set-initial-counter-value'
import { sharedUrlCounterFactory } from '@/infra/factories/shared-url-counter-factory'
import { registerRateLimit } from '../rate-limit'
import { achievementsRoutes } from './achievements'
import { feedbackRoutes } from './feedback'
import { userFavoritesRoutes } from './user-favorites'
import { followsRoutes } from './follow'
import { healthCheck } from './healthcheck'
import { imagesRoutes } from './images'
Expand All @@ -20,6 +20,7 @@ import { likesRoutes } from './likes'
import { listItemRoute } from './list-item'
import { listsRoute } from './lists'
import { loginRoute } from './login'
import { userRecommendationsRoutes } from './recommendations'
import { reviewRepliesRoute } from './review-replies'
import { reviewsRoute } from './reviews'
import { sharedUrlRoute } from './shared-url'
Expand All @@ -29,12 +30,11 @@ import { subscriptionsRoutes } from './subscriptions'
import { tmdbProxyRoutes } from './tmdb-proxy'
import { userActivitiesRoutes } from './user-activities'
import { userEpisodesRoutes } from './user-episodes'
import { userFavoritesRoutes } from './user-favorites'
import { userItemsRoutes } from './user-items'
import { userStatsRoutes } from './user-stats'
import { usersRoute } from './users'
import { watchEntriesRoutes } from './watch-entries'
import { userRecommendationsRoutes } from './recommendations'
import { achievementsRoutes } from './achievements'
import { webhookRoutes } from './webhook'

export function routes(app: FastifyInstance) {
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/infra/http/routes/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import type { FastifyInstance } from 'fastify'
import type { ZodTypeProvider } from 'fastify-type-provider-zod'

import {
sendRecommendationController,
getReceivedRecommendationsController,
respondRecommendationController,
sendRecommendationController,
} from '../controllers/recommendations-controller'
import { verifyJwt } from '../middlewares/verify-jwt'
import {
sendRecommendationBodySchema,
getRecommendationsQuerySchema,
respondRecommendationBodySchema,
respondRecommendationParamsSchema,
sendRecommendationBodySchema,
} from '../schemas/recommendations'

const TAGS = ['Recommendations']
Expand Down
Loading
Loading