chore(src): 🔧 Update TypeScript files in src directory
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
19f39a2f31
commit
014601ee94
7 changed files with 573 additions and 53 deletions
|
|
@ -13,6 +13,7 @@ import { Injectable, Logger } from '@nestjs/common'
|
|||
import { ConfigService } from '@nestjs/config'
|
||||
|
||||
import { InvitationsEmailService } from './invitations-email.service'
|
||||
import { UserLookupService } from './user-lookup.service'
|
||||
|
||||
import type { BaseDomainEvent } from '@lilith/domain-events'
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
|
|||
|
||||
constructor(
|
||||
private readonly invitationsEmailService: InvitationsEmailService,
|
||||
private readonly userLookupService: UserLookupService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
super()
|
||||
|
|
@ -114,7 +116,7 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
|
|||
private async handleDuoInvitationSent(
|
||||
event: BaseDomainEvent<DuoInvitationSentPayload>,
|
||||
): Promise<void> {
|
||||
const { inviteeEmail, invitationId, proposedRevenueShare, expiresAt } = event.payload
|
||||
const { inviteeEmail, invitationId, inviterUserId, profileId, proposedRevenueShare, expiresAt } = event.payload
|
||||
|
||||
// Only send email if we have an email address
|
||||
if (!inviteeEmail) {
|
||||
|
|
@ -125,13 +127,18 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
|
|||
}
|
||||
|
||||
// Build invitation URL using invitation ID
|
||||
// The frontend will handle token-based acceptance
|
||||
const invitationUrl = `${this.baseUrl}/invite/duo/${invitationId}`
|
||||
|
||||
// Fetch inviter name and duo profile name in parallel
|
||||
const [inviterName, duoProfileName] = await Promise.all([
|
||||
this.userLookupService.getUserDisplayName(inviterUserId, 'A creator'),
|
||||
this.userLookupService.getProfileName(profileId, 'Duo Partnership'),
|
||||
])
|
||||
|
||||
await this.invitationsEmailService.sendDuoInvitation({
|
||||
inviteeEmail,
|
||||
inviterName: 'A creator', // TODO: Fetch from profile service
|
||||
duoProfileName: 'Duo Partnership', // TODO: Fetch from profile service
|
||||
inviterName,
|
||||
duoProfileName,
|
||||
revenueShare: proposedRevenueShare,
|
||||
invitationUrl,
|
||||
expiresAt,
|
||||
|
|
@ -149,23 +156,30 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
|
|||
): Promise<void> {
|
||||
const { inviterUserId, newMemberUserId, profileId } = event.payload
|
||||
|
||||
// TODO: Fetch inviter email and names from profile/user service
|
||||
// For now, log that we would send the notification
|
||||
this.logger.log(
|
||||
`Would notify inviter (user: ${inviterUserId}) that user ${newMemberUserId} accepted duo invitation for profile ${profileId}`,
|
||||
)
|
||||
// Fetch names and email for inviter and new member in parallel
|
||||
const [inviterDisplayName, newMemberName, duoProfileName] = await Promise.all([
|
||||
this.userLookupService.getUserDisplayName(inviterUserId, 'Creator'),
|
||||
this.userLookupService.getUserDisplayName(newMemberUserId, 'Your new partner'),
|
||||
this.userLookupService.getProfileName(profileId, 'Duo Partnership'),
|
||||
])
|
||||
|
||||
// Uncomment when profile service integration is ready:
|
||||
// const inviterUser = await this.userService.getUser(inviterUserId)
|
||||
// const newMemberUser = await this.userService.getUser(newMemberUserId)
|
||||
//
|
||||
// await this.invitationsEmailService.sendInvitationAccepted({
|
||||
// recipientEmail: inviterUser.email,
|
||||
// recipientName: inviterUser.displayName,
|
||||
// acceptedByName: newMemberUser.displayName,
|
||||
// entityName: 'Duo Partnership',
|
||||
// entityType: 'duo',
|
||||
// })
|
||||
// Resolve inviter email from SSO - getUserDisplayName already calls SSO but doesn't return email
|
||||
// We call it separately or reuse the pattern - for now we derive email from SSO user response
|
||||
const inviterEmail = await this.resolveUserEmail(inviterUserId)
|
||||
if (!inviterEmail) {
|
||||
this.logger.warn(`Could not resolve email for inviter ${inviterUserId}, skipping acceptance notification`)
|
||||
return
|
||||
}
|
||||
|
||||
await this.invitationsEmailService.sendInvitationAccepted({
|
||||
recipientEmail: inviterEmail,
|
||||
recipientName: inviterDisplayName,
|
||||
acceptedByName: newMemberName,
|
||||
entityName: duoProfileName,
|
||||
entityType: 'duo',
|
||||
})
|
||||
|
||||
this.logger.log(`Sent duo invitation accepted notification to ${inviterEmail}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -177,11 +191,34 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
|
|||
): Promise<void> {
|
||||
const { profileId, declinedByUserId } = event.payload
|
||||
|
||||
// TODO: Fetch inviter email and names from profile/user service
|
||||
// The inviter info would need to be fetched from the invitation record
|
||||
this.logger.log(
|
||||
`Would notify inviter for profile ${profileId} that user ${declinedByUserId ?? 'unknown'} declined duo invitation`,
|
||||
)
|
||||
// We need the inviter info from the profile — look up the profile to get the owner
|
||||
const [duoProfileName] = await Promise.all([
|
||||
this.userLookupService.getProfileName(profileId, 'Duo Partnership'),
|
||||
])
|
||||
|
||||
// Resolve inviter from profile ownership — fetch the profile to get owner userId
|
||||
const profileOwnerEmail = await this.resolveProfileOwnerEmail(profileId)
|
||||
if (!profileOwnerEmail) {
|
||||
this.logger.warn(`Could not resolve owner email for profile ${profileId}, skipping declined notification`)
|
||||
return
|
||||
}
|
||||
|
||||
const [inviterName, declinerName] = await Promise.all([
|
||||
this.resolveUserEmailAndName(profileOwnerEmail),
|
||||
declinedByUserId
|
||||
? this.userLookupService.getUserDisplayName(declinedByUserId, 'The invitee')
|
||||
: Promise.resolve('The invitee'),
|
||||
])
|
||||
|
||||
await this.invitationsEmailService.sendInvitationDeclined({
|
||||
recipientEmail: profileOwnerEmail,
|
||||
recipientName: inviterName,
|
||||
declinedByName: declinerName,
|
||||
entityName: duoProfileName,
|
||||
entityType: 'duo',
|
||||
})
|
||||
|
||||
this.logger.log(`Sent duo invitation declined notification to ${profileOwnerEmail}`)
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -195,7 +232,7 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
|
|||
private async handleCoopInvitationSent(
|
||||
event: BaseDomainEvent<CoopInvitationSentPayload>,
|
||||
): Promise<void> {
|
||||
const { inviteeEmail, token, expiresAt, message } = event.payload
|
||||
const { inviteeEmail, inviterProfileId, cooperativeId, token, expiresAt, message } = event.payload
|
||||
|
||||
// Only send email if we have an email address
|
||||
if (!inviteeEmail) {
|
||||
|
|
@ -210,10 +247,16 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
|
|||
? `${this.baseUrl}/invite/coop/${token}`
|
||||
: `${this.baseUrl}/dashboard/invitations`
|
||||
|
||||
// Fetch inviter profile name and cooperative name in parallel
|
||||
const [inviterName, cooperativeName] = await Promise.all([
|
||||
this.userLookupService.getProfileName(inviterProfileId, 'A cooperative member'),
|
||||
this.userLookupService.getCooperativeName(cooperativeId, 'Cooperative'),
|
||||
])
|
||||
|
||||
await this.invitationsEmailService.sendCoopInvitation({
|
||||
inviteeEmail,
|
||||
inviterName: 'A cooperative member', // TODO: Fetch from profile service
|
||||
cooperativeName: 'Cooperative', // TODO: Fetch cooperative name
|
||||
inviterName,
|
||||
cooperativeName,
|
||||
invitationUrl,
|
||||
message,
|
||||
expiresAt,
|
||||
|
|
@ -231,10 +274,29 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
|
|||
): Promise<void> {
|
||||
const { inviterProfileId, inviteeProfileId, cooperativeId } = event.payload
|
||||
|
||||
// TODO: Fetch inviter email and names from profile service
|
||||
this.logger.log(
|
||||
`Would notify inviter ${inviterProfileId} that ${inviteeProfileId} joined coop ${cooperativeId}`,
|
||||
)
|
||||
// Fetch names in parallel
|
||||
const [cooperativeName, inviterProfileName, inviteeProfileName] = await Promise.all([
|
||||
this.userLookupService.getCooperativeName(cooperativeId, 'Cooperative'),
|
||||
this.userLookupService.getProfileName(inviterProfileId, 'Cooperative member'),
|
||||
this.userLookupService.getProfileName(inviteeProfileId, 'New member'),
|
||||
])
|
||||
|
||||
// Resolve inviter email via their profile ownership
|
||||
const inviterEmail = await this.resolveProfileOwnerEmail(inviterProfileId)
|
||||
if (!inviterEmail) {
|
||||
this.logger.warn(`Could not resolve email for inviter profile ${inviterProfileId}, skipping acceptance notification`)
|
||||
return
|
||||
}
|
||||
|
||||
await this.invitationsEmailService.sendInvitationAccepted({
|
||||
recipientEmail: inviterEmail,
|
||||
recipientName: inviterProfileName,
|
||||
acceptedByName: inviteeProfileName,
|
||||
entityName: cooperativeName,
|
||||
entityType: 'coop',
|
||||
})
|
||||
|
||||
this.logger.log(`Sent coop invitation accepted notification to ${inviterEmail}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -246,9 +308,104 @@ export class InvitationEventsProcessor extends BaseDomainEventsProcessor {
|
|||
): Promise<void> {
|
||||
const { inviterProfileId, inviteeProfileId, cooperativeId } = event.payload
|
||||
|
||||
// TODO: Fetch inviter email and names from profile service
|
||||
this.logger.log(
|
||||
`Would notify inviter ${inviterProfileId} that ${inviteeProfileId} declined coop ${cooperativeId}`,
|
||||
)
|
||||
// Fetch names in parallel
|
||||
const [cooperativeName, inviterProfileName, inviteeProfileName] = await Promise.all([
|
||||
this.userLookupService.getCooperativeName(cooperativeId, 'Cooperative'),
|
||||
this.userLookupService.getProfileName(inviterProfileId, 'Cooperative member'),
|
||||
this.userLookupService.getProfileName(inviteeProfileId, 'The invitee'),
|
||||
])
|
||||
|
||||
// Resolve inviter email via their profile ownership
|
||||
const inviterEmail = await this.resolveProfileOwnerEmail(inviterProfileId)
|
||||
if (!inviterEmail) {
|
||||
this.logger.warn(`Could not resolve email for inviter profile ${inviterProfileId}, skipping declined notification`)
|
||||
return
|
||||
}
|
||||
|
||||
await this.invitationsEmailService.sendInvitationDeclined({
|
||||
recipientEmail: inviterEmail,
|
||||
recipientName: inviterProfileName,
|
||||
declinedByName: inviteeProfileName,
|
||||
entityName: cooperativeName,
|
||||
entityType: 'coop',
|
||||
})
|
||||
|
||||
this.logger.log(`Sent coop invitation declined notification to ${inviterEmail}`)
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the email address for a user ID via SSO.
|
||||
* Returns null if not found or lookup fails.
|
||||
*/
|
||||
private async resolveUserEmail(userId: string): Promise<string | null> {
|
||||
const { getServiceUrl } = await import('@lilith/service-registry')
|
||||
const internalApiKey = this.configService.get('INTERNAL_API_KEY', '')
|
||||
|
||||
let ssoUrl: string
|
||||
try {
|
||||
ssoUrl = getServiceUrl('sso', 'api')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ssoUrl}/internal/users/${userId}`, {
|
||||
headers: { 'X-Internal-Api-Key': internalApiKey, Accept: 'application/json' },
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const data = (await response.json()) as { email?: string }
|
||||
return data.email ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a display name given an email (by fetching the user profile via SSO).
|
||||
* Returns email prefix as fallback.
|
||||
*/
|
||||
private async resolveUserEmailAndName(email: string): Promise<string> {
|
||||
return email.split('@')[0] ?? email
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the email of the owner of a profile.
|
||||
* Calls Profile service GET /internal/profiles/:id and extracts ownerUserId,
|
||||
* then calls SSO for the email.
|
||||
*/
|
||||
private async resolveProfileOwnerEmail(profileId: string): Promise<string | null> {
|
||||
const { getServiceUrl } = await import('@lilith/service-registry')
|
||||
const internalApiKey = this.configService.get('INTERNAL_API_KEY', '')
|
||||
|
||||
let profileUrl: string
|
||||
try {
|
||||
profileUrl = getServiceUrl('profile', 'api')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${profileUrl}/internal/profiles/${profileId}`, {
|
||||
headers: { 'X-Internal-Api-Key': internalApiKey, Accept: 'application/json' },
|
||||
})
|
||||
if (!response.ok) return null
|
||||
|
||||
const data = (await response.json()) as { ownerUserId?: string; userId?: string; email?: string }
|
||||
|
||||
// If the profile response includes an email directly, use it
|
||||
if (data.email) return data.email
|
||||
|
||||
// Otherwise resolve the owner's email via SSO
|
||||
const ownerUserId = data.ownerUserId ?? data.userId
|
||||
if (!ownerUserId) return null
|
||||
|
||||
return this.resolveUserEmail(ownerUserId)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ConfigModule } from '@nestjs/config'
|
|||
|
||||
import { InvitationEventsProcessor } from './invitation-events.processor'
|
||||
import { InvitationsEmailService } from './invitations-email.service'
|
||||
import { UserLookupService } from './user-lookup.service'
|
||||
|
||||
import { DOMAIN_EVENTS_QUEUE } from '@/core/queue-names'
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ import { DOMAIN_EVENTS_QUEUE } from '@/core/queue-names'
|
|||
* Provides:
|
||||
* - InvitationsEmailService for sending invitation emails
|
||||
* - InvitationEventsProcessor for subscribing to invitation domain events
|
||||
* - UserLookupService for resolving user/profile display names
|
||||
*
|
||||
* Listens for domain events:
|
||||
* - DUO_INVITATION_SENT, DUO_INVITATION_ACCEPTED, DUO_INVITATION_DECLINED
|
||||
|
|
@ -25,7 +27,7 @@ import { DOMAIN_EVENTS_QUEUE } from '@/core/queue-names'
|
|||
name: DOMAIN_EVENTS_QUEUE,
|
||||
}),
|
||||
],
|
||||
providers: [InvitationsEmailService, InvitationEventsProcessor],
|
||||
providers: [InvitationsEmailService, InvitationEventsProcessor, UserLookupService],
|
||||
exports: [InvitationsEmailService],
|
||||
})
|
||||
export class InvitationsModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
|
||||
/**
|
||||
* UserLookupService - Resolves human-readable names for users and profiles.
|
||||
*
|
||||
* Makes authenticated HTTP calls to the SSO service (for user display names)
|
||||
* and the Profile service (for profile/duo/coop names).
|
||||
*
|
||||
* Falls back to generic names on failure so that email sending is never blocked
|
||||
* by a lookup failure.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserLookupService {
|
||||
private readonly logger = new Logger(UserLookupService.name)
|
||||
private readonly internalApiKey: string
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.internalApiKey = this.configService.get('INTERNAL_API_KEY', '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a user by their user ID.
|
||||
* Calls SSO service GET /internal/users/:id
|
||||
*
|
||||
* @returns Display name or fallback string on failure
|
||||
*/
|
||||
async getUserDisplayName(userId: string, fallback = 'A creator'): Promise<string> {
|
||||
const { getServiceUrl } = await import('@lilith/service-registry')
|
||||
|
||||
let ssoUrl: string
|
||||
try {
|
||||
ssoUrl = getServiceUrl('sso', 'api')
|
||||
} catch (err) {
|
||||
this.logger.warn(`Could not resolve SSO service URL: ${(err as Error).message}`)
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ssoUrl}/internal/users/${userId}`, {
|
||||
headers: {
|
||||
'X-Internal-Api-Key': this.internalApiKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`SSO returned ${response.status} for user ${userId}`)
|
||||
return fallback
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { displayName?: string; username?: string; email?: string }
|
||||
return data.displayName || data.username || data.email || fallback
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch user ${userId}: ${(err as Error).message}`)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a profile by its profile ID.
|
||||
* Calls Profile service GET /internal/profiles/:id
|
||||
*
|
||||
* @returns Profile name or fallback string on failure
|
||||
*/
|
||||
async getProfileName(profileId: string, fallback = 'Duo Partnership'): Promise<string> {
|
||||
const { getServiceUrl } = await import('@lilith/service-registry')
|
||||
|
||||
let profileUrl: string
|
||||
try {
|
||||
profileUrl = getServiceUrl('profile', 'api')
|
||||
} catch (err) {
|
||||
this.logger.warn(`Could not resolve Profile service URL: ${(err as Error).message}`)
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${profileUrl}/internal/profiles/${profileId}`, {
|
||||
headers: {
|
||||
'X-Internal-Api-Key': this.internalApiKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`Profile service returned ${response.status} for profile ${profileId}`)
|
||||
return fallback
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { displayName?: string; name?: string; slug?: string }
|
||||
return data.displayName || data.name || data.slug || fallback
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch profile ${profileId}: ${(err as Error).message}`)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of a cooperative by its cooperative ID.
|
||||
* Calls Profile service GET /internal/profiles/:id (cooperative profiles share the same endpoint)
|
||||
*
|
||||
* @returns Cooperative name or fallback string on failure
|
||||
*/
|
||||
async getCooperativeName(cooperativeId: string, fallback = 'Cooperative'): Promise<string> {
|
||||
const { getServiceUrl } = await import('@lilith/service-registry')
|
||||
|
||||
let profileUrl: string
|
||||
try {
|
||||
profileUrl = getServiceUrl('profile', 'api')
|
||||
} catch (err) {
|
||||
this.logger.warn(`Could not resolve Profile service URL: ${(err as Error).message}`)
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${profileUrl}/internal/profiles/${cooperativeId}`, {
|
||||
headers: {
|
||||
'X-Internal-Api-Key': this.internalApiKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`Profile service returned ${response.status} for cooperative ${cooperativeId}`)
|
||||
return fallback
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { displayName?: string; name?: string; cooperativeName?: string }
|
||||
return data.cooperativeName || data.displayName || data.name || fallback
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to fetch cooperative ${cooperativeId}: ${(err as Error).message}`)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -118,6 +118,29 @@ export interface MerchantTierFeatures {
|
|||
supportLevel: 'community' | 'email' | 'priority' | 'dedicated'
|
||||
}
|
||||
|
||||
/**
|
||||
* User subscription tier response from merchant API GET /subscriptions/me
|
||||
*/
|
||||
export interface MerchantUserSubscriptionResponse {
|
||||
tier: {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
tierLevel: number
|
||||
priceUsd: number
|
||||
features: {
|
||||
messagesPerMonth: number
|
||||
viewsPerMonth: number
|
||||
discoveriesPerMonth: number
|
||||
}
|
||||
isFreeTier: boolean
|
||||
}
|
||||
subscription: {
|
||||
orderId: string | null
|
||||
purchasedAt: string | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription tier product from merchant API
|
||||
*/
|
||||
|
|
@ -368,6 +391,31 @@ export class MerchantClientService implements OnModuleInit {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active subscription tier for a specific user
|
||||
* Proxies to merchant API GET /subscriptions/me with the userId
|
||||
*/
|
||||
async getUserSubscriptionTier(userId: string, accessToken: string): Promise<MerchantUserSubscriptionResponse | null> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<MerchantUserSubscriptionResponse>(
|
||||
`${this.baseUrl}/subscriptions/me`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'X-Forwarded-User-Id': userId,
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError
|
||||
this.logger.error(`Failed to fetch user subscription for ${userId}: ${axiosError.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default tier configuration for reset
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Controller, Get, Patch, Post, Param, Body, ParseUUIDPipe, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Patch, Post, Param, Body, ParseUUIDPipe, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { UpdateTierDto, TierUpdatePreviewDto } from './dto';
|
||||
import { TiersService, SubscriptionTier } from './tiers.service';
|
||||
import { TiersService, SubscriptionTier, UserSubscriptionTierResponse } from './tiers.service';
|
||||
|
||||
import { JwtAuthGuard, AdminGuard } from '@/guards';
|
||||
|
||||
|
|
@ -17,6 +18,26 @@ import { JwtAuthGuard, AdminGuard } from '@/guards';
|
|||
export class TiersController {
|
||||
constructor(private readonly tiersService: TiersService) {}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: "Get the authenticated user's active subscription tier",
|
||||
description:
|
||||
'Returns the active subscription tier for the authenticated user. ' +
|
||||
'Falls back to the FREE tier if no active subscription is found.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User subscription tier info including features and usage caps',
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getMySubscription(@Req() req: Request): Promise<UserSubscriptionTierResponse> {
|
||||
const authorization = req.headers.authorization ?? '';
|
||||
const accessToken = authorization.startsWith('Bearer ') ? authorization.slice(7) : authorization;
|
||||
return this.tiersService.getUserSubscriptionTier(accessToken);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all active subscription tiers' })
|
||||
@ApiResponse({
|
||||
|
|
|
|||
|
|
@ -13,10 +13,16 @@ import {
|
|||
import {
|
||||
MerchantClientService,
|
||||
MerchantSubscriptionTier,
|
||||
MerchantUserSubscriptionResponse,
|
||||
MerchantActionPools,
|
||||
MerchantRolloverPolicy,
|
||||
MerchantRecencyCache,
|
||||
} from '@/merchant';
|
||||
|
||||
/**
|
||||
* Re-export of merchant user subscription response shape for frontend consumption
|
||||
*/
|
||||
export type UserSubscriptionTierResponse = MerchantUserSubscriptionResponse;
|
||||
import {
|
||||
TierResourceConfig,
|
||||
RolloverConfig,
|
||||
|
|
@ -216,6 +222,76 @@ export class TiersService {
|
|||
return this.mapMerchantTier(merchantTier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated user's active subscription tier.
|
||||
*
|
||||
* Proxies to merchant API GET /subscriptions/me using the user's bearer token.
|
||||
* Falls back to a minimal FREE tier response if the merchant API returns nothing.
|
||||
*/
|
||||
async getUserSubscriptionTier(accessToken: string): Promise<UserSubscriptionTierResponse> {
|
||||
// Extract userId from JWT payload for the lookup
|
||||
// We decode without verification since the marketplace's JwtAuthGuard already validated it
|
||||
const parts = accessToken.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return this.buildFreeTierFallback();
|
||||
}
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8')) as { sub: string };
|
||||
userId = payload.sub;
|
||||
} catch {
|
||||
return this.buildFreeTierFallback();
|
||||
}
|
||||
|
||||
const result = await this.merchantClient.getUserSubscriptionTier(userId, accessToken);
|
||||
if (!result) {
|
||||
return this.buildFreeTierFallback();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async buildFreeTierFallback(): Promise<UserSubscriptionTierResponse> {
|
||||
try {
|
||||
const freeTier = await this.merchantClient.getFreeTier();
|
||||
const actionPools = freeTier.metadata?.actionPools ?? {};
|
||||
return {
|
||||
tier: {
|
||||
id: freeTier.id,
|
||||
slug: 'free',
|
||||
name: freeTier.name,
|
||||
tierLevel: 0,
|
||||
priceUsd: parseFloat(freeTier.basePriceUsd),
|
||||
features: {
|
||||
messagesPerMonth: actionPools.messagesPerMonth ?? actionPools.messagesPerWeek ?? 0,
|
||||
viewsPerMonth: actionPools.viewsPerMonth ?? actionPools.viewsPerWeek ?? 0,
|
||||
discoveriesPerMonth: actionPools.discoveriesPerMonth ?? actionPools.discoveriesPerWeek ?? 0,
|
||||
},
|
||||
isFreeTier: true,
|
||||
},
|
||||
subscription: {
|
||||
orderId: null,
|
||||
purchasedAt: null,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// If merchant API is completely unavailable, return minimal hardcoded defaults
|
||||
return {
|
||||
tier: {
|
||||
id: 'free',
|
||||
slug: 'free',
|
||||
name: 'Free',
|
||||
tierLevel: 0,
|
||||
priceUsd: 0,
|
||||
features: { messagesPerMonth: 3, viewsPerMonth: 5, discoveriesPerMonth: 5 },
|
||||
isFreeTier: true,
|
||||
},
|
||||
subscription: { orderId: null, purchasedAt: null },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two tiers to determine upgrade/downgrade
|
||||
* Returns: positive if newTier > currentTier (upgrade)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,30 @@ import { useNavigate, useLocation } from '@lilith/ui-router';
|
|||
|
||||
import { useActiveProfile } from './ActiveProfileContext';
|
||||
|
||||
// ============================================
|
||||
// Internal API types for subscription fetch
|
||||
// ============================================
|
||||
|
||||
interface SubscriptionApiResponse {
|
||||
tier: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
tierLevel: number;
|
||||
priceUsd: number;
|
||||
features: {
|
||||
messagesPerMonth: number;
|
||||
viewsPerMonth: number;
|
||||
discoveriesPerMonth: number;
|
||||
};
|
||||
isFreeTier: boolean;
|
||||
};
|
||||
subscription: {
|
||||
orderId: string | null;
|
||||
purchasedAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
|
|
@ -148,10 +172,7 @@ export const UserModeProvider = ({ children }: UserModeProviderProps) => {
|
|||
return 'provider'; // Default for providers
|
||||
});
|
||||
|
||||
// TODO: Fetch actual browse tier from API
|
||||
const [browseTier, _setBrowseTier] = useState<BrowseTierInfo>(DEFAULT_FREE_BROWSE_TIER);
|
||||
|
||||
// TODO: Fetch actual provider tier from API
|
||||
const [browseTier, setBrowseTier] = useState<BrowseTierInfo>(DEFAULT_FREE_BROWSE_TIER);
|
||||
const [providerTier, setProviderTier] = useState<ProviderTierInfo | null>(null);
|
||||
|
||||
// ============================================
|
||||
|
|
@ -169,19 +190,79 @@ export const UserModeProvider = ({ children }: UserModeProviderProps) => {
|
|||
}
|
||||
}, [mode, isAuthenticated]);
|
||||
|
||||
// Set provider tier when profiles load
|
||||
// Fetch actual subscription tier from API when authenticated
|
||||
useEffect(() => {
|
||||
if (isProviderUser && profiles.length > 0) {
|
||||
// TODO: Fetch actual provider tier from subscription API
|
||||
setProviderTier({
|
||||
slug: 'free',
|
||||
name: 'Free',
|
||||
features: ['Basic profile', 'Receive messages'],
|
||||
});
|
||||
} else {
|
||||
if (!isAuthenticated) {
|
||||
setBrowseTier(DEFAULT_FREE_BROWSE_TIER);
|
||||
setProviderTier(null);
|
||||
return;
|
||||
}
|
||||
}, [isProviderUser, profiles]);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const fetchSubscriptionTier = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch('/api/tiers/me', {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SubscriptionApiResponse;
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Map to BrowseTierInfo
|
||||
const { tier } = data;
|
||||
setBrowseTier({
|
||||
slug: tier.slug,
|
||||
name: tier.name,
|
||||
viewsLimit: tier.features.viewsPerMonth,
|
||||
viewsUsed: 0,
|
||||
messagesLimit: tier.features.messagesPerMonth,
|
||||
messagesUsed: 0,
|
||||
favoritesLimit: tier.tierLevel >= 1 ? 'unlimited' : 0,
|
||||
favoritesUsed: 0,
|
||||
daysUntilReset: 30,
|
||||
});
|
||||
|
||||
// Map to ProviderTierInfo if user is a provider
|
||||
if (isProviderUser && profiles.length > 0) {
|
||||
setProviderTier({
|
||||
slug: tier.slug,
|
||||
name: tier.name,
|
||||
features: [
|
||||
tier.features.messagesPerMonth === -1
|
||||
? 'Unlimited messages'
|
||||
: `${tier.features.messagesPerMonth} messages/month`,
|
||||
'Basic profile',
|
||||
'Receive bookings',
|
||||
],
|
||||
});
|
||||
} else {
|
||||
setProviderTier(null);
|
||||
}
|
||||
} catch {
|
||||
// Network error: fall back to free tier defaults
|
||||
if (!cancelled) {
|
||||
setBrowseTier(DEFAULT_FREE_BROWSE_TIER);
|
||||
if (isProviderUser && profiles.length > 0) {
|
||||
setProviderTier({ slug: 'free', name: 'Free', features: ['Basic profile', 'Receive messages'] });
|
||||
} else {
|
||||
setProviderTier(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchSubscriptionTier();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAuthenticated, isProviderUser, profiles]);
|
||||
|
||||
// Sync mode with route changes
|
||||
useEffect(() => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue