Files
Sauci/apps/api/src/domains/profile-settings/repository.ts
T
jjbruntonandClaude Opus 5 ce0b188776 feat(dares): ship the dares loop to mobile
Completes the half-built dares feature: send a dare, respond, report done,
confirm, and get a push notification on every transition.

Backend (apps/api):
- New `dares` domain with couple-shared premium entitlement, a free weekly
  send allowance, and server-enforced actor/state rules on every transition.
- `submitted` status so the recipient reports completion and the sender
  confirms, rather than the sender guessing.
- Notifications ride the existing operations outbox via a trigger on
  `sent_dares`. Push copy is generic; store compliance forbids dare text in a
  preview and a test asserts no payload leaks it.
- Worker expiry sweep now covers `pending` as well as `active`/`submitted`;
  an invitation nobody opened previously stayed open forever.

Fixes two latent defects:
- Deleting any dare that had been sent aborted with a check violation, because
  `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check`
  required it. Dare text is now snapshotted at send time, which also keeps a
  couple's history stable across catalogue edits.
- The free send allowance was raceable: concurrent requests all read the same
  count and every one succeeded. The count and insert are now serialized on
  the sender's profile row.

Also ports `content_status` review metadata to the API catalogue, which had no
store-safety gating at all, and mirrors the review decision already enforced on
the legacy catalogue.

Mobile: dares screen (active/send/history), send sheet with duration presets,
home tile badging incoming dares, paywall on both proactive and 402 paths.

Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against
local PostgreSQL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:03:11 +01:00

149 lines
5.6 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { Pool, type QueryResultRow } from 'pg';
import type {
FeedbackSubmission,
NotificationPreferenceKey,
NotificationPreferences,
ProfileUpdate,
} from './types.js';
interface PreferencesRow extends QueryResultRow {
user_id: string;
matches_enabled: boolean;
messages_enabled: boolean;
partner_activity_enabled: boolean;
nudges_enabled: boolean;
pack_changes_enabled: boolean;
new_packs_enabled: boolean;
streak_milestones_enabled: boolean;
weekly_summary_enabled: boolean;
unpaired_reminders_enabled: boolean;
catchup_reminders_enabled: boolean;
dares_enabled: boolean;
created_at: Date;
updated_at: Date;
}
export class ProfileSettingsError extends Error {
constructor(readonly code: 'profile_not_found') {
super(code === 'profile_not_found' ? 'Profile not found. Please complete signup first.' : code);
}
}
export interface ProfileSettingsRepository {
updateProfile(userId: string, update: ProfileUpdate): Promise<void>;
updateLastActive(userId: string): Promise<void>;
getNotificationPreferences(userId: string): Promise<NotificationPreferences>;
updateNotificationPreference(userId: string, key: NotificationPreferenceKey, value: boolean): Promise<NotificationPreferences>;
submitFeedback(userId: string, submission: FeedbackSubmission): Promise<{ id: string; created_at: string }>;
close(): Promise<void>;
}
const profileColumns: Record<keyof ProfileUpdate, string> = {
name: 'name',
gender: 'gender',
usage_reason: 'usage_reason',
max_intensity: 'max_intensity',
show_explicit_content: 'show_explicit_content',
hide_nsfw: 'hide_nsfw',
onboarding_completed: 'onboarding_completed',
onboarding_version: 'onboarding_version',
public_key_jwk: 'public_key_jwk',
push_token: 'push_token',
};
function toPreferences(row: PreferencesRow): NotificationPreferences {
return {
user_id: row.user_id,
matches_enabled: row.matches_enabled,
messages_enabled: row.messages_enabled,
partner_activity_enabled: row.partner_activity_enabled,
nudges_enabled: row.nudges_enabled,
pack_changes_enabled: row.pack_changes_enabled,
new_packs_enabled: row.new_packs_enabled,
streak_milestones_enabled: row.streak_milestones_enabled,
weekly_summary_enabled: row.weekly_summary_enabled,
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
catchup_reminders_enabled: row.catchup_reminders_enabled,
dares_enabled: row.dares_enabled,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
};
}
function assertChanged(rowCount: number | null): void {
if (rowCount === 0) throw new ProfileSettingsError('profile_not_found');
}
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
const entries = Object.entries(update) as [keyof ProfileUpdate, ProfileUpdate[keyof ProfileUpdate]][];
if (entries.length === 0) return;
const assignments = entries.map(([key], index) => `${profileColumns[key]} = $${index + 2}`);
const values = entries.map(([, value]) => value);
const result = await this.pool.query(
`update profiles set ${assignments.join(', ')}, updated_at = now() where id = $1`,
[userId, ...values],
);
assertChanged(result.rowCount);
}
async updateLastActive(userId: string): Promise<void> {
const result = await this.pool.query(
'update profiles set last_active_at = now(), updated_at = now() where id = $1',
[userId],
);
assertChanged(result.rowCount);
}
async getNotificationPreferences(userId: string): Promise<NotificationPreferences> {
const result = await this.pool.query<PreferencesRow>(
`insert into notification_preferences (user_id) values ($1)
on conflict (user_id) do update set user_id = excluded.user_id
returning *`,
[userId],
);
return toPreferences(result.rows[0]!);
}
async updateNotificationPreference(
userId: string,
key: NotificationPreferenceKey,
value: boolean,
): Promise<NotificationPreferences> {
await this.getNotificationPreferences(userId);
const result = await this.pool.query<PreferencesRow>(
`update notification_preferences set ${key} = $2, updated_at = now() where user_id = $1 returning *`,
[userId, value],
);
return toPreferences(result.rows[0]!);
}
async submitFeedback(
userId: string,
submission: FeedbackSubmission,
): Promise<{ id: string; created_at: string }> {
const id = randomUUID();
const result = await this.pool.query<{ id: string; created_at: Date }>(
`insert into feedback (id, user_id, type, title, description, question_id, device_info, screenshot_media_id)
select $1, $2, $3, $4, $5, $6, $7, mo.id from (select $8::uuid id) requested
left join media_objects mo on mo.id=requested.id and mo.owner_id=$2 and mo.kind='feedback' and mo.deleted_at is null
where requested.id is null or mo.id is not null returning id, created_at`,
[id, userId, submission.type, submission.title, submission.description,
submission.question_id ?? null, submission.device_info ?? {}, submission.screenshot_media_id ?? null],
);
if (!result.rows[0]) throw new Error('Feedback screenshot is not accessible');
return { id: result.rows[0]!.id, created_at: result.rows[0]!.created_at.toISOString() };
}
async close(): Promise<void> {
await this.pool.end();
}
}