diff --git a/AGENTS.md b/AGENTS.md index 5dfdfcb6..a981701b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,16 @@ the API boots on `PORT` and answers `GET /health`, and the Next.js web app builds. Production runs on Railway (services `api` and `web`); deploys are **human-only**. +## Noctusoft platforms + +This product does not sign up for OpenAI, Twilio, or SendGrid. It calls two Noctusoft platforms. The product id is the only difference. + +- **LLM Relay** — OpenAI-compatible API at `https://ai.noctusoft.com/v1` on litellm-vm (Azure `20.46.250.159`, Tailscale `100.112.233.46`). Virtual key. No provider SDK. Not noctusoft-relay on `ns`. +- **Mail** — `POST /email/send` or SendGrid drop-in `POST /v3/mail/send` on `api.sendgrid.noctusoft.com`, with the product key and `X-App-Env` (`dev` captures in smtp4dev, `uat` tags and sends, anything else is real delivery). +- **Text** — `POST /sms/send` or Twilio drop-in on `api.twilio.noctusoft.com`. +- **One store** — the product's billing alias on noctusoft-relay. Square item codes are `NOCTU-{PRODUCT}-…`. The Square category is `Noctusoft — {brand}`. +- **Marketplace** — Connect Hub, the FieldView shape: `POST /connect/{productKey}/recipients/{seller}/charge`. The seller is the merchant. The fee lives on that product's `connect_apps` row. + ## Layout pnpm workspace (`pnpm-workspace.yaml`: `packages/*`, `apps/*`). diff --git a/apps/api/src/routes/__tests__/webhooks.marketplace.v1.test.ts b/apps/api/src/routes/__tests__/webhooks.marketplace.v1.test.ts index 50b1433d..6c462dc3 100644 --- a/apps/api/src/routes/__tests__/webhooks.marketplace.v1.test.ts +++ b/apps/api/src/routes/__tests__/webhooks.marketplace.v1.test.ts @@ -6,13 +6,14 @@ import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest'; const getById = vi.fn(); const update = vi.fn(); +const getByPaymentProviderId = vi.fn(); vi.mock('../../lib/prisma', () => ({ prisma: { viewerIdentity: { findUnique: vi.fn().mockResolvedValue({ email: 'viewer@example.com' }) } }, })); vi.mock('../../repositories/implementations/PurchaseRepository', () => ({ - PurchaseRepository: vi.fn().mockImplementation(() => ({ getById, update, getByPaymentProviderId: vi.fn() })), + PurchaseRepository: vi.fn().mockImplementation(() => ({ getById, update, getByPaymentProviderId })), })); import type { PurchaseFulfillmentService } from '../../services/PurchaseFulfillmentService'; @@ -70,6 +71,7 @@ describe('POST /api/webhooks/marketplace (event v1)', () => { vi.stubEnv('FIELDVIEW_WEBHOOK_CALLBACK_URL', CALLBACK); getById.mockReset(); update.mockReset(); + getByPaymentProviderId.mockReset(); fulfillPaidPurchase.mockClear(); setPurchaseFulfillmentService({ fulfillPaidPurchase } as unknown as PurchaseFulfillmentService); }); @@ -105,6 +107,29 @@ describe('POST /api/webhooks/marketplace (event v1)', () => { expect(update).toHaveBeenCalledWith('purchase_1', expect.objectContaining({ status: 'failed' })); }); + it('marks a partial refund as partially_refunded', async () => { + getByPaymentProviderId.mockResolvedValue({ id: 'purchase_1', status: 'paid', amountCents: 500 }); + const res = await send( + v1('marketplace.refund.completed', { + money: { amountCents: 500, currency: 'USD', refundedCents: 200, feeCents: 50 }, + }), + ); + expect(res.status).toBe(200); + expect(getByPaymentProviderId).toHaveBeenCalledWith('pay_1'); + expect(update).toHaveBeenCalledWith('purchase_1', expect.objectContaining({ status: 'partially_refunded' })); + }); + + it('marks a full refund as refunded', async () => { + getByPaymentProviderId.mockResolvedValue({ id: 'purchase_1', status: 'paid', amountCents: 500 }); + const res = await send( + v1('marketplace.refund.completed', { + money: { amountCents: 500, currency: 'USD', refundedCents: 500, feeCents: 50 }, + }), + ); + expect(res.status).toBe(200); + expect(update).toHaveBeenCalledWith('purchase_1', expect.objectContaining({ status: 'refunded' })); + }); + it('rejects an event signed with another key', async () => { const res = await send(v1('marketplace.purchase.paid'), 'wrong'); expect(res.status).toBe(401); diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index ee6ecd36..b6b8c0fb 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -17,6 +17,7 @@ import { validateRequest } from '../middleware/validation'; import { AdminAccountRepository } from '../repositories/implementations/AdminAccountRepository'; import { EntitlementRepository } from '../repositories/implementations/EntitlementRepository'; import { GameRepository } from '../repositories/implementations/GameRepository'; +import { LedgerRepository } from '../repositories/implementations/LedgerRepository'; import { PlaybackSessionRepository } from '../repositories/implementations/PlaybackSessionRepository'; import { PurchaseRepository } from '../repositories/implementations/PurchaseRepository'; import { RefundRepository } from '../repositories/implementations/RefundRepository'; @@ -27,6 +28,7 @@ import { AudienceService } from '../services/AudienceService'; import { getMarketplaceConfig } from '../lib/marketplace'; import { OwnerAccountRepository } from '../repositories/implementations/OwnerAccountRepository'; import type { IRefundWriter } from '../services/IRefundService'; +import { LedgerService } from '../services/LedgerService'; import { RefundService } from '../services/RefundService'; import { MarketplaceStoreService } from '../services/MarketplaceStoreService'; import { SmsService } from '../services/SmsService'; @@ -95,6 +97,7 @@ function getRefundService(): IRefundWriter { const ownerRepo = new OwnerAccountRepository(prisma); const smsService = new SmsService(gameRepo, viewerIdentityRepo, viewerIdentityRepo); const marketplace = new MarketplaceStoreService(getMarketplaceConfig()); + const ledgerRepo = new LedgerRepository(prisma); refundServiceInstance = new RefundService( purchaseRepo, purchaseRepo, @@ -104,7 +107,9 @@ function getRefundService(): IRefundWriter { entitlementRepo, smsService, ownerRepo, - marketplace + marketplace, + new LedgerService(ledgerRepo, ownerRepo), + ledgerRepo ); } return refundServiceInstance; diff --git a/apps/api/src/routes/webhooks.marketplace.ts b/apps/api/src/routes/webhooks.marketplace.ts index 9cba87b6..fb821b68 100644 --- a/apps/api/src/routes/webhooks.marketplace.ts +++ b/apps/api/src/routes/webhooks.marketplace.ts @@ -30,7 +30,7 @@ export interface MarketplaceWebhookEvent { id?: string; type?: string; buyer?: { userId?: string | null } | null; - money?: { amountCents?: number | null; feeCents?: number | null } | null; + money?: { amountCents?: number | null; feeCents?: number | null; refundedCents?: number | null } | null; refs?: { paymentId?: string | null } | null; seller?: { key?: string | null; chargesEnabled?: boolean } | null; data?: { @@ -173,7 +173,13 @@ router.post('/marketplace', (req, res, next) => { if (paymentId) { const purchase = await purchaseRepo.getByPaymentProviderId(paymentId); if (purchase) { - await purchaseRepo.update(purchase.id, { status: 'refunded', refundedAt: new Date() }); + // money.refundedCents is the total refunded so far; without it, assume a full refund. + const refundedCents = event.money?.refundedCents; + const isPartial = typeof refundedCents === 'number' && refundedCents < purchase.amountCents; + await purchaseRepo.update(purchase.id, { + status: isPartial ? 'partially_refunded' : 'refunded', + refundedAt: new Date(), + }); } } } diff --git a/apps/api/src/services/RefundService.ts b/apps/api/src/services/RefundService.ts index 8da85410..db999e22 100644 --- a/apps/api/src/services/RefundService.ts +++ b/apps/api/src/services/RefundService.ts @@ -5,18 +5,23 @@ * Handles refund evaluation and processing. */ +import type { Purchase, Refund } from '@prisma/client'; + import { BadRequestError, NotFoundError } from '../lib/errors'; +import { logger } from '../lib/logger'; import { resolveSellerKey } from '../lib/marketplace'; +import type { IEntitlementReader as IEntitlementRepoReader } from '../repositories/IEntitlementRepository'; +import type { ILedgerReader } from '../repositories/ILedgerRepository'; import type { IOwnerAccountReader } from '../repositories/IOwnerAccountRepository'; import type { IPlaybackSessionReader } from '../repositories/IPlaybackSessionRepository'; import type { IPurchaseReader, IPurchaseWriter } from '../repositories/IPurchaseRepository'; import type { IRefundReader as IRefundRepoReader, IRefundWriter as IRefundRepoWriter } from '../repositories/IRefundRepository'; import { calculateRefund, type RefundCalculationInput } from '../utils/refundCalculator'; -import type { IEntitlementReader as IEntitlementRepoReader } from '../repositories/IEntitlementRepository'; -import type { IMarketplaceStorePayments } from './MarketplaceStoreService'; import type { IRefundReader, IRefundWriter, AggregatedTelemetry, RefundEvaluation } from './IRefundService'; import type { ISmsWriter } from './ISmsService'; +import type { LedgerService } from './LedgerService'; +import type { IMarketplaceStorePayments } from './MarketplaceStoreService'; const DEFAULT_GAME_DURATION_MS = 90 * 60 * 1000; // 90 minutes @@ -30,7 +35,9 @@ export class RefundService implements IRefundReader, IRefundWriter { private entitlementReader: IEntitlementRepoReader, private smsWriter: ISmsWriter, private ownerReader: IOwnerAccountReader, - private marketplace: IMarketplaceStorePayments + private marketplace: IMarketplaceStorePayments, + private ledgerService: Pick, + private ledgerReader: Pick ) {} async evaluateRefundEligibility(purchaseId: string): Promise { @@ -117,10 +124,11 @@ export class RefundService implements IRefundReader, IRefundWriter { throw new NotFoundError('Purchase not found'); } - // Check if already refunded - const existingRefunds = await this.refundReader.getByPurchaseId(purchaseId); - if (existingRefunds.length > 0) { - throw new BadRequestError('Purchase already refunded'); + // Check if already refunded, or retry a refund whose store call failed + const pending = this.findRetryableRefund(await this.refundReader.getByPurchaseId(purchaseId)); + if (pending) { + await this.processMarketplaceRefund(pending.id); + return pending; } // Re-evaluate to get refund amount @@ -148,15 +156,8 @@ export class RefundService implements IRefundReader, IRefundWriter { }, }); - // Update purchase status + // Refund on the store; this also updates the purchase status once the store accepts it const refundAmount = evaluation.amountCents; - const isFullRefund = refundAmount >= purchase.amountCents; - await this.purchaseWriter.update(purchaseId, { - status: isFullRefund ? 'refunded' : 'partially_refunded', - refundedAt: new Date(), - }); - - // Process Square refund await this.processMarketplaceRefund(refund.id); // Send SMS notification @@ -189,9 +190,15 @@ export class RefundService implements IRefundReader, IRefundWriter { if (!purchase) { throw new NotFoundError('Purchase not found'); } - const existingRefunds = await this.refundReader.getByPurchaseId(purchaseId); - if (existingRefunds.length > 0) { - throw new BadRequestError('Purchase already refunded'); + const pending = this.findRetryableRefund(await this.refundReader.getByPurchaseId(purchaseId)); + if (pending) { + if (pending.amountCents !== amountCents) { + throw new BadRequestError( + `A refund of ${pending.amountCents} cents is already pending for this purchase; retry with that amount` + ); + } + await this.processMarketplaceRefund(pending.id); + return pending; } if (amountCents <= 0 || amountCents > purchase.amountCents) { throw new BadRequestError('Invalid refund amount'); @@ -215,17 +222,22 @@ export class RefundService implements IRefundReader, IRefundWriter { }, }); - const isFullRefund = amountCents >= purchase.amountCents; - await this.purchaseWriter.update(purchaseId, { - status: isFullRefund ? 'refunded' : 'partially_refunded', - refundedAt: new Date(), - }); - await this.processMarketplaceRefund(refund.id); return refund; } + /** + * A refund whose store call failed stays unprocessed. Retrying it reuses its id, + * which is the store's idempotency key, so a retry can never refund twice. + */ + private findRetryableRefund(existingRefunds: Refund[]): Refund | undefined { + if (existingRefunds.some((r) => r.processedAt)) { + throw new BadRequestError('Purchase already refunded'); + } + return existingRefunds[0]; + } + async processSquareRefund(refundId: string): Promise { await this.processMarketplaceRefund(refundId); } @@ -261,6 +273,18 @@ export class RefundService implements IRefundReader, IRefundWriter { refundId, reason: refund.reasonCode, }); + + // Update the purchase only after the store accepts the refund. processedAt goes + // last, so a failure in between leaves the refund retryable (every step is idempotent). + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const isFullRefund = refund.amountCents >= (purchase.amountCents as number); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + await this.purchaseWriter.update(purchase.id, { + status: isFullRefund ? 'refunded' : 'partially_refunded', + refundedAt: new Date(), + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await this.writeRefundLedger(purchase, refund.amountCents, refundId); await this.refundWriter.update(refundId, { processedAt: new Date() }); return; } @@ -269,6 +293,18 @@ export class RefundService implements IRefundReader, IRefundWriter { throw new BadRequestError('Owner marketplace seller is not configured for this purchase'); } + private async writeRefundLedger(purchase: Purchase, amountCents: number, refundId: string): Promise { + try { + const existing = await this.ledgerReader.findByReference('refund', refundId); + if (existing.length === 0) { + await this.ledgerService.createRefundLedgerEntries(purchase, amountCents, refundId); + } + } catch (ledgerError) { + // The money has already moved; a missing ledger row must not fail the refund. + logger.error({ ledgerError, refundId }, 'Failed to create refund ledger entries'); + } + } + /** * Aggregate telemetry from all playback sessions for a purchase */ diff --git a/apps/api/src/services/__tests__/RefundService.test.ts b/apps/api/src/services/__tests__/RefundService.test.ts index 9ecdfc8b..ee9687a7 100644 --- a/apps/api/src/services/__tests__/RefundService.test.ts +++ b/apps/api/src/services/__tests__/RefundService.test.ts @@ -2,21 +2,54 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { RefundService } from '../RefundService'; -describe('RefundService.processMarketplaceRefund', () => { +const purchase = { + id: 'purchase-1', + amountCents: 500, + platformFeeCents: 50, + currency: 'USD', + status: 'paid', + paymentProviderPaymentId: 'pay-1', + recipientOwnerAccountId: 'owner-1', +}; + +function pendingRefund(over: Record = {}) { + return { + id: 'refund-1', + purchaseId: 'purchase-1', + amountCents: 500, + reasonCode: 'TEST', + processedAt: null, + purchase, + ...over, + }; +} + +describe('RefundService', () => { const purchaseReader = { getById: vi.fn() }; const purchaseWriter = { update: vi.fn() }; const playbackSessionReader = { listByEntitlementId: vi.fn() }; const refundReader = { getById: vi.fn(), getByPurchaseId: vi.fn() }; const refundWriter = { create: vi.fn(), update: vi.fn() }; const entitlementReader = { getByPurchaseId: vi.fn() }; - const smsWriter = { sendPaymentLink: vi.fn() }; + const smsWriter = { sendPaymentLink: vi.fn(), sendNotification: vi.fn() }; const ownerReader = { findById: vi.fn() }; const marketplace = { refund: vi.fn(), createCheckout: vi.fn() }; + const ledgerService = { createRefundLedgerEntries: vi.fn() }; + const ledgerReader = { findByReference: vi.fn() }; let svc: RefundService; beforeEach(() => { vi.clearAllMocks(); + ownerReader.findById.mockResolvedValue({ id: 'owner-1', marketplaceSellerKey: 'owner-1' }); + purchaseReader.getById.mockResolvedValue(purchase); + refundReader.getByPurchaseId.mockResolvedValue([]); + refundWriter.create.mockImplementation((data: { amountCents: number }) => + Promise.resolve(pendingRefund({ amountCents: data.amountCents })), + ); + refundReader.getById.mockResolvedValue(pendingRefund()); + marketplace.refund.mockResolvedValue(undefined); + ledgerReader.findByReference.mockResolvedValue([]); svc = new RefundService( purchaseReader as never, purchaseWriter as never, @@ -27,21 +60,108 @@ describe('RefundService.processMarketplaceRefund', () => { smsWriter as never, ownerReader as never, marketplace as never, + ledgerService as never, + ledgerReader as never, ); }); - it('calls marketplace refund API', async () => { - refundReader.getById.mockResolvedValue({ - id: 'refund-1', - amountCents: 500, - reasonCode: 'TEST', - processedAt: null, - purchase: { paymentProviderPaymentId: 'pay-1', recipientOwnerAccountId: 'owner-1' }, + describe('processMarketplaceRefund', () => { + it('calls marketplace refund API', async () => { + await svc.processMarketplaceRefund('refund-1'); + expect(marketplace.refund).toHaveBeenCalledWith( + expect.objectContaining({ paymentId: 'pay-1', sellerKey: 'owner-1' }), + ); + }); + + it('marks the purchase refunded only after the store accepts the refund', async () => { + await svc.processMarketplaceRefund('refund-1'); + expect(purchaseWriter.update).toHaveBeenCalledWith( + 'purchase-1', + expect.objectContaining({ status: 'refunded' }), + ); + expect(marketplace.refund.mock.invocationCallOrder[0]).toBeLessThan( + purchaseWriter.update.mock.invocationCallOrder[0], + ); + }); + + it('marks a partial refund as partially_refunded', async () => { + refundReader.getById.mockResolvedValue(pendingRefund({ amountCents: 200 })); + await svc.processMarketplaceRefund('refund-1'); + expect(purchaseWriter.update).toHaveBeenCalledWith( + 'purchase-1', + expect.objectContaining({ status: 'partially_refunded' }), + ); + }); + + it('leaves the purchase and refund untouched when the store rejects the refund', async () => { + marketplace.refund.mockRejectedValue(new Error('store down')); + await expect(svc.processMarketplaceRefund('refund-1')).rejects.toThrow('store down'); + expect(purchaseWriter.update).not.toHaveBeenCalled(); + expect(refundWriter.update).not.toHaveBeenCalled(); + expect(ledgerService.createRefundLedgerEntries).not.toHaveBeenCalled(); + }); + + it('writes the ledger reversal for the refund', async () => { + refundReader.getById.mockResolvedValue(pendingRefund({ amountCents: 200 })); + await svc.processMarketplaceRefund('refund-1'); + expect(ledgerService.createRefundLedgerEntries).toHaveBeenCalledWith( + expect.objectContaining({ id: 'purchase-1' }), + 200, + 'refund-1', + ); + }); + + it('does not write the ledger reversal twice', async () => { + ledgerReader.findByReference.mockResolvedValue([{ id: 'ledger-1' }]); + await svc.processMarketplaceRefund('refund-1'); + expect(ledgerReader.findByReference).toHaveBeenCalledWith('refund', 'refund-1'); + expect(ledgerService.createRefundLedgerEntries).not.toHaveBeenCalled(); + }); + + it('still completes the refund when the ledger write fails', async () => { + ledgerService.createRefundLedgerEntries.mockRejectedValue(new Error('db')); + await expect(svc.processMarketplaceRefund('refund-1')).resolves.toBeUndefined(); + expect(refundWriter.update).toHaveBeenCalledWith( + 'refund-1', + expect.objectContaining({ processedAt: expect.any(Date) as Date }), + ); + }); + + it('does nothing for a refund that was already processed', async () => { + refundReader.getById.mockResolvedValue(pendingRefund({ processedAt: new Date() })); + await svc.processMarketplaceRefund('refund-1'); + expect(marketplace.refund).not.toHaveBeenCalled(); + }); + }); + + describe('issueManualRefund', () => { + it('does not mark the purchase refunded when the store rejects the refund', async () => { + marketplace.refund.mockRejectedValue(new Error('store down')); + await expect(svc.issueManualRefund('purchase-1', 500, 'admin_manual', 'admin-1')).rejects.toThrow(); + expect(purchaseWriter.update).not.toHaveBeenCalled(); + }); + + it('retries a pending refund with the same idempotency key instead of refusing', async () => { + refundReader.getByPurchaseId.mockResolvedValue([pendingRefund()]); + const refund = await svc.issueManualRefund('purchase-1', 500, 'admin_manual', 'admin-1'); + expect(refund.id).toBe('refund-1'); + expect(refundWriter.create).not.toHaveBeenCalled(); + expect(marketplace.refund).toHaveBeenCalledWith(expect.objectContaining({ refundId: 'refund-1' })); + }); + + it('refuses a different amount while another refund is pending', async () => { + refundReader.getByPurchaseId.mockResolvedValue([pendingRefund()]); + await expect(svc.issueManualRefund('purchase-1', 200, 'admin_manual', 'admin-1')).rejects.toThrow( + /already pending/, + ); + expect(marketplace.refund).not.toHaveBeenCalled(); + }); + + it('refuses once the purchase has a processed refund', async () => { + refundReader.getByPurchaseId.mockResolvedValue([pendingRefund({ processedAt: new Date() })]); + await expect(svc.issueManualRefund('purchase-1', 500, 'admin_manual', 'admin-1')).rejects.toThrow( + /already refunded/, + ); }); - ownerReader.findById.mockResolvedValue({ id: 'owner-1', marketplaceSellerKey: 'owner-1' }); - await svc.processMarketplaceRefund('refund-1'); - expect(marketplace.refund).toHaveBeenCalledWith( - expect.objectContaining({ paymentId: 'pay-1', sellerKey: 'owner-1' }), - ); }); }); diff --git a/apps/web/__tests__/e2e/seek-protection.spec.ts b/apps/web/__tests__/e2e/seek-protection.spec.ts index 6f9e1ca8..34f149f2 100644 --- a/apps/web/__tests__/e2e/seek-protection.spec.ts +++ b/apps/web/__tests__/e2e/seek-protection.spec.ts @@ -1,14 +1,27 @@ /** * Seek Overlay E2E Tests * - * Verifies the tap-to-reveal seek overlay, Go Live button, and keyboard seeking. + * Verifies the long-press seek overlay, Go Live button, and keyboard seeking. * Uses /demo/seek-protection which renders VidstackPlayer with SeekOverlay. */ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; const SEEK_PROTECTION_URL = '/demo/seek-protection'; +async function revealSeekOverlay(page: Page): Promise { + const zone = page.getByTestId('seek-overlay-longpress-zone'); + await zone.waitFor({ state: 'visible' }); + const box = await zone.boundingBox(); + if (!box) { + throw new Error('seek-overlay-longpress-zone has no bounding box'); + } + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(2100); + await page.mouse.up(); +} + test.describe('Seek overlay', () => { test.beforeEach(async ({ page }) => { await page.goto(SEEK_PROTECTION_URL); @@ -16,15 +29,25 @@ test.describe('Seek overlay', () => { await page.waitForTimeout(2000); }); - test('tapping center trigger zone shows seek overlay buttons', async ({ page }) => { - const trigger = page.getByTestId('seek-overlay-trigger'); - await trigger.click(); + test('a tap does not show seek overlay buttons', async ({ page }) => { + await page.getByTestId('seek-overlay-longpress-zone').click(); + await expect(page.getByTestId('seek-overlay-buttons')).not.toBeVisible(); + }); + + test('holding 2s shows seek overlay buttons', async ({ page }) => { + await revealSeekOverlay(page); await expect(page.getByTestId('seek-overlay-buttons')).toBeVisible({ timeout: 2000 }); }); + test('tapping elsewhere on the page dismisses the overlay', async ({ page }) => { + await revealSeekOverlay(page); + await expect(page.getByTestId('seek-overlay-buttons')).toBeVisible({ timeout: 2000 }); + await page.locator('body').click({ position: { x: 8, y: 8 }, force: true }); + await expect(page.getByTestId('seek-overlay-buttons')).not.toBeVisible(); + }); + test('tapping backdrop dismisses the overlay', async ({ page }) => { - const trigger = page.getByTestId('seek-overlay-trigger'); - await trigger.click(); + await revealSeekOverlay(page); await expect(page.getByTestId('seek-overlay-buttons')).toBeVisible({ timeout: 2000 }); const backdrop = page.getByTestId('seek-overlay-backdrop'); const box = await backdrop.boundingBox(); @@ -38,8 +61,7 @@ test.describe('Seek overlay', () => { }); test('seek overlay auto-dismisses after idle timeout', async ({ page }) => { - const trigger = page.getByTestId('seek-overlay-trigger'); - await trigger.click(); + await revealSeekOverlay(page); await expect(page.getByTestId('seek-overlay-buttons')).toBeVisible({ timeout: 2000 }); await page.waitForTimeout(5500); await expect(page.getByTestId('seek-overlay-buttons')).not.toBeVisible(); diff --git a/apps/web/components/DirectStreamPageBase.tsx b/apps/web/components/DirectStreamPageBase.tsx index 7126aa4d..675fc171 100644 --- a/apps/web/components/DirectStreamPageBase.tsx +++ b/apps/web/components/DirectStreamPageBase.tsx @@ -39,6 +39,7 @@ import { ViewerAnalyticsPanel } from '@/components/ViewerAnalyticsPanel'; import { PaywallModal } from '@/components/PaywallModal'; // v2 Components import { StreamPlayer } from '@/components/v2/video/StreamPlayer'; +import { SeekHoldHint } from '@/components/v2/video/SeekHoldHint'; import { useFullscreen } from '@/hooks/v2/useFullscreen'; import { Chat, ReportEventSheet } from '@/components/v2/chat'; import { AdminBroadcast } from '@/components/v2/chat/AdminBroadcast'; @@ -1183,6 +1184,8 @@ export function DirectStreamPageBase({ config, children }: DirectStreamPageBaseP )} + + {/* Bookmark controls removed — bookmarks accessed via drawer (right-edge tab / portrait tab / mobile bottom sheet) */} {/* Real-time bookmark toast notifications (portrait) */} @@ -1878,6 +1881,8 @@ export function DirectStreamPageBase({ config, children }: DirectStreamPageBaseP )} + + {/* Bookmark controls removed — bookmarks accessed via drawer (right-edge tab / portrait tab / mobile bottom sheet) */} {/* Real-time bookmark toast notifications */} @@ -1936,8 +1941,8 @@ export function DirectStreamPageBase({ config, children }: DirectStreamPageBaseP )} {/* Touch-friendly hint */} {!isFullscreen && isTouch && ( -

- 💡 Tap video for controls • Tap 📊 for score • Tap 💬 to chat +

+ 💡 Hold the video 2s to skip • Tap 📊 for score • Tap 💬 to chat

)} diff --git a/apps/web/components/v2/video/SeekHoldHint.tsx b/apps/web/components/v2/video/SeekHoldHint.tsx new file mode 100644 index 00000000..dc1f1d81 --- /dev/null +++ b/apps/web/components/v2/video/SeekHoldHint.tsx @@ -0,0 +1,60 @@ +'use client'; + +/** + * One-time first-visit tip: hold the video to skip/rewind/go live. + * Stored in localStorage so it only appears until dismissed. + */ + +import { useCallback, useEffect, useState } from 'react'; + +export const SEEK_HOLD_HINT_KEY = 'fv_seek_hold_hint_seen'; + +export function SeekHoldHint() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + try { + if (window.localStorage.getItem(SEEK_HOLD_HINT_KEY) === 'true') return; + } catch { + return; + } + setVisible(true); + }, []); + + const dismiss = useCallback(() => { + try { + window.localStorage.setItem(SEEK_HOLD_HINT_KEY, 'true'); + } catch { + // ignore quota / private-mode + } + setVisible(false); + }, []); + + if (!visible) return null; + + return ( +
+

+ Hold the video for a couple of seconds +

+

+ Skip forward, rewind, or jump back to live. +

+ +
+ ); +} diff --git a/apps/web/components/v2/video/SeekOverlay.tsx b/apps/web/components/v2/video/SeekOverlay.tsx index 392db8ef..dfab35fc 100644 --- a/apps/web/components/v2/video/SeekOverlay.tsx +++ b/apps/web/components/v2/video/SeekOverlay.tsx @@ -1,17 +1,17 @@ 'use client'; /** - * SeekOverlay - Tap-to-reveal frosted-glass seek strip overlaid on the video player. + * SeekOverlay - Long-press frosted-glass seek strip overlaid on the video player. * - * Trigger: tap the center 20% of the video, or long-press (2s) anywhere above controls. + * Trigger: hold 2s anywhere on the video above the native controls (not a tap). * Shows a 9-segment icon strip: |◀ ◀◀◀ ◀◀ ◀ ⏸/▶ ▶ ▶▶ ▶▶▶ ▶| - * Dismiss: tap outside the strip, or auto-dismiss after 3s idle. + * Dismiss: tap anywhere else (tabs, chrome, video, backdrop), or auto-dismiss after 3s idle. * * IMPORTANT: The overlay uses pointer-events-none by default and excludes the * bottom 60px so native player controls (volume, fullscreen, etc.) remain accessible. */ -import { useCallback, type MouseEvent } from 'react'; +import { useCallback, useEffect, useRef, type MouseEvent } from 'react'; import { useSeekOverlay } from '@/hooks/v2/useSeekOverlay'; /* ─── SVG Icon Helpers ─── */ @@ -114,8 +114,24 @@ export interface SeekOverlayProps { } export function SeekOverlay({ onSeek, onTogglePause, onGoToStart, onGoLive, isPaused }: SeekOverlayProps) { - const { isVisible, show, dismiss, resetAutoDismiss, longPressHandlers } = + const { isVisible, dismiss, resetAutoDismiss, longPressHandlers } = useSeekOverlay(); + const buttonsRef = useRef(null); + + useEffect(() => { + if (!isVisible) return; + + const onPointerDownCapture = (event: PointerEvent) => { + const buttons = buttonsRef.current; + if (buttons && event.target instanceof Node && buttons.contains(event.target)) { + return; + } + dismiss(); + }; + + document.addEventListener('pointerdown', onPointerDownCapture, true); + return () => document.removeEventListener('pointerdown', onPointerDownCapture, true); + }, [isVisible, dismiss]); const handleSegmentClick = useCallback( (action: SegmentAction) => (e: MouseEvent) => { @@ -139,18 +155,6 @@ export function SeekOverlay({ onSeek, onTogglePause, onGoToStart, onGoLive, isPa [onSeek, onTogglePause, onGoToStart, onGoLive, resetAutoDismiss] ); - const handleCenterTap = useCallback( - (e: MouseEvent) => { - e.stopPropagation(); - if (isVisible) { - dismiss(); - } else { - show(); - } - }, - [isVisible, show, dismiss] - ); - const handleBackdropClick = useCallback( (e: MouseEvent) => { if (e.target === e.currentTarget) { @@ -164,19 +168,13 @@ export function SeekOverlay({ onSeek, onTogglePause, onGoToStart, onGoLive, isPa
{/* Long-press detection zone — excludes bottom 60px for native controls */}
e.preventDefault()} + aria-label="Hold two seconds to show skip controls" data-testid="seek-overlay-longpress-zone" - > - {/* Center 20% trigger zone */} -
-
+ /> {/* Visible strip — also excludes bottom 60px */}
diff --git a/apps/web/components/v2/video/__tests__/MuxStreamPlayer.test.tsx b/apps/web/components/v2/video/__tests__/MuxStreamPlayer.test.tsx index fd07884c..6854fa21 100644 --- a/apps/web/components/v2/video/__tests__/MuxStreamPlayer.test.tsx +++ b/apps/web/components/v2/video/__tests__/MuxStreamPlayer.test.tsx @@ -2,12 +2,10 @@ import { forwardRef, useEffect, useImperativeHandle, - useRef, type ReactNode, } from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { MuxStreamPlayer } from '../MuxStreamPlayer'; function mockSeekable(ranges: Array<{ start: number; end: number }>): TimeRanges { @@ -57,10 +55,24 @@ vi.mock('@mux/mux-player-react', () => ({ describe('MuxStreamPlayer seek overlay', () => { beforeEach(() => { + vi.useFakeTimers(); mediaState.currentTime = 5000; mediaState.seekable = mockSeekable([{ start: 120, end: 5100 }]); }); + afterEach(() => { + vi.useRealTimers(); + }); + + async function revealOverlay() { + fireEvent.pointerDown(screen.getByTestId('seek-overlay-longpress-zone'), { + button: 0, + }); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + } + it('passes live:dvr streamType to Mux element', () => { render(); expect(screen.getByTestId('mock-mux')).toHaveAttribute( @@ -70,26 +82,23 @@ describe('MuxStreamPlayer seek overlay', () => { }); it('seeks -30s within seekable range', async () => { - const user = userEvent.setup(); render(); - await user.click(screen.getByTestId('seek-overlay-trigger')); - await user.click(screen.getByTestId('seek-overlay-btn--30')); + await revealOverlay(); + fireEvent.click(screen.getByTestId('seek-overlay-btn--30')); expect(mediaState.currentTime).toBe(4970); }); it('go-to-start uses seekable start not zero', async () => { - const user = userEvent.setup(); render(); - await user.click(screen.getByTestId('seek-overlay-trigger')); - await user.click(screen.getByTestId('seek-overlay-btn-start')); + await revealOverlay(); + fireEvent.click(screen.getByTestId('seek-overlay-btn-start')); expect(mediaState.currentTime).toBe(120); }); it('go-live seeks to seekable end', async () => { - const user = userEvent.setup(); render(); - await user.click(screen.getByTestId('seek-overlay-trigger')); - await user.click(screen.getByTestId('seek-overlay-btn-live')); + await revealOverlay(); + fireEvent.click(screen.getByTestId('seek-overlay-btn-live')); expect(mediaState.currentTime).toBe(5100); }); }); diff --git a/apps/web/components/v2/video/__tests__/SeekHoldHint.test.tsx b/apps/web/components/v2/video/__tests__/SeekHoldHint.test.tsx new file mode 100644 index 00000000..65b2d1e0 --- /dev/null +++ b/apps/web/components/v2/video/__tests__/SeekHoldHint.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { SEEK_HOLD_HINT_KEY, SeekHoldHint } from '../SeekHoldHint'; + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + get length() { + return Object.keys(store).length; + }, + key: vi.fn((i: number) => Object.keys(store)[i] ?? null), + }; +})(); +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }); + +describe('SeekHoldHint', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + }); + + it('shows first-visit skip instructions when not seen before', async () => { + render(); + const hint = await screen.findByTestId('hint-seek-hold-first'); + expect(hint).toHaveTextContent(/hold the video for a couple of seconds/i); + expect(hint).toHaveTextContent(/skip forward/i); + expect(hint).toHaveTextContent(/rewind/i); + expect(hint).toHaveTextContent(/live/i); + }); + + it('does not show after it has been seen', async () => { + localStorageMock.setItem(SEEK_HOLD_HINT_KEY, 'true'); + render(); + await Promise.resolve(); + expect(screen.queryByTestId('hint-seek-hold-first')).not.toBeInTheDocument(); + }); + + it('dismisses and does not show again', async () => { + const { unmount } = render(); + fireEvent.click(await screen.findByTestId('btn-dismiss-seek-hold-hint')); + expect(screen.queryByTestId('hint-seek-hold-first')).not.toBeInTheDocument(); + expect(localStorageMock.getItem(SEEK_HOLD_HINT_KEY)).toBe('true'); + + unmount(); + render(); + expect(screen.queryByTestId('hint-seek-hold-first')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/v2/video/__tests__/SeekOverlay.test.tsx b/apps/web/components/v2/video/__tests__/SeekOverlay.test.tsx index 304c144d..3918f693 100644 --- a/apps/web/components/v2/video/__tests__/SeekOverlay.test.tsx +++ b/apps/web/components/v2/video/__tests__/SeekOverlay.test.tsx @@ -39,37 +39,74 @@ describe('SeekOverlay', () => { expect(backdrop.className).toContain('opacity-100'); } + async function revealByLongPress() { + const zone = screen.getByTestId('seek-overlay-longpress-zone'); + fireEvent.pointerDown(zone, { button: 0 }); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + } + it('starts hidden (opacity-0)', () => { render(); expectHidden(); }); - it('shows buttons when center trigger is clicked', () => { + it('does not show on tap or click', () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); - expectVisible(); + fireEvent.click(screen.getByTestId('seek-overlay-longpress-zone')); + expectHidden(); + expect(screen.queryByTestId('seek-overlay-trigger')).not.toBeInTheDocument(); }); - it('dismisses when backdrop is clicked', () => { + it('does not show if the press is released before 2s', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + const zone = screen.getByTestId('seek-overlay-longpress-zone'); + fireEvent.pointerDown(zone, { button: 0 }); + await act(async () => { + vi.advanceTimersByTime(1999); + }); + fireEvent.pointerUp(zone); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + expectHidden(); + }); + + it('dismisses when backdrop is clicked', async () => { + render(); + await revealByLongPress(); expectVisible(); fireEvent.click(screen.getByTestId('seek-overlay-backdrop')); expectHidden(); }); - it('toggles visibility on repeated center taps and backdrop clicks', () => { - render(); - const trigger = screen.getByTestId('seek-overlay-trigger'); - - fireEvent.click(trigger); + it('dismisses when tapping elsewhere on the screen (e.g. a tab)', async () => { + render( +
+ + +
+ ); + await revealByLongPress(); expectVisible(); - fireEvent.click(screen.getByTestId('seek-overlay-backdrop')); + fireEvent.pointerDown(screen.getByTestId('tab-chat'), { button: 0 }); expectHidden(); }); + it('does not dismiss when pressing a skip button', async () => { + render(); + await revealByLongPress(); + expectVisible(); + + fireEvent.pointerDown(screen.getByTestId('seek-overlay-btn--10'), { button: 0 }); + expectVisible(); + }); + it('renders all 9 segment buttons', () => { render(); expect(screen.getByTestId('seek-overlay-btn-start')).toBeInTheDocument(); @@ -83,9 +120,9 @@ describe('SeekOverlay', () => { expect(screen.getByTestId('seek-overlay-btn-live')).toBeInTheDocument(); }); - it('calls onSeek with correct deltas for seek buttons', () => { + it('calls onSeek with correct deltas for seek buttons', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); fireEvent.click(screen.getByTestId('seek-overlay-btn--30')); expect(onSeek).toHaveBeenCalledWith(-30); @@ -106,25 +143,25 @@ describe('SeekOverlay', () => { expect(onSeek).toHaveBeenCalledWith(30); }); - it('calls onTogglePause when pause button is clicked', () => { + it('calls onTogglePause when pause button is clicked', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); fireEvent.click(screen.getByTestId('seek-overlay-btn-pause')); expect(onTogglePause).toHaveBeenCalledTimes(1); expect(onSeek).not.toHaveBeenCalled(); }); - it('calls onGoToStart when start button is clicked', () => { + it('calls onGoToStart when start button is clicked', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); fireEvent.click(screen.getByTestId('seek-overlay-btn-start')); expect(onGoToStart).toHaveBeenCalledTimes(1); expect(onSeek).not.toHaveBeenCalled(); }); - it('calls onGoLive when live button is clicked', () => { + it('calls onGoLive when live button is clicked', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); fireEvent.click(screen.getByTestId('seek-overlay-btn-live')); expect(onGoLive).toHaveBeenCalledTimes(1); expect(onSeek).not.toHaveBeenCalled(); @@ -144,7 +181,7 @@ describe('SeekOverlay', () => { it('auto-dismisses after 3 seconds of inactivity', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); expectVisible(); await act(async () => { @@ -155,7 +192,7 @@ describe('SeekOverlay', () => { it('button press resets auto-dismiss timer', async () => { render(); - fireEvent.click(screen.getByTestId('seek-overlay-trigger')); + await revealByLongPress(); // Advance 2 seconds await act(async () => { @@ -197,6 +234,14 @@ describe('SeekOverlay', () => { expect(root.className).toContain('pointer-events-none'); }); + it('long-press zone is labeled for assistive tech', () => { + render(); + expect(screen.getByTestId('seek-overlay-longpress-zone')).toHaveAttribute( + 'aria-label', + 'Hold two seconds to show skip controls' + ); + }); + it('long-press zone excludes bottom 60px for native controls', () => { render(); const zone = screen.getByTestId('seek-overlay-longpress-zone'); diff --git a/apps/web/components/v2/video/__tests__/VidstackPlayer.test.tsx b/apps/web/components/v2/video/__tests__/VidstackPlayer.test.tsx index 95abbc09..8145fe13 100644 --- a/apps/web/components/v2/video/__tests__/VidstackPlayer.test.tsx +++ b/apps/web/components/v2/video/__tests__/VidstackPlayer.test.tsx @@ -1,7 +1,6 @@ import { forwardRef, useImperativeHandle, type ReactNode } from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { VidstackPlayer } from '../VidstackPlayer'; const playerState = { @@ -61,30 +60,41 @@ vi.mock('@vidstack/react/player/layouts/default', () => ({ describe('VidstackPlayer seek overlay', () => { beforeEach(() => { + vi.useFakeTimers(); playerState.currentTime = 5000; }); + afterEach(() => { + vi.useRealTimers(); + }); + + async function revealOverlay() { + fireEvent.pointerDown(screen.getByTestId('seek-overlay-longpress-zone'), { + button: 0, + }); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + } + it('seeks -30s within seekable range', async () => { - const user = userEvent.setup(); render(); - await user.click(screen.getByTestId('seek-overlay-trigger')); - await user.click(screen.getByTestId('seek-overlay-btn--30')); + await revealOverlay(); + fireEvent.click(screen.getByTestId('seek-overlay-btn--30')); expect(playerState.currentTime).toBe(4970); }); it('go-to-start uses seekable start', async () => { - const user = userEvent.setup(); render(); - await user.click(screen.getByTestId('seek-overlay-trigger')); - await user.click(screen.getByTestId('seek-overlay-btn-start')); + await revealOverlay(); + fireEvent.click(screen.getByTestId('seek-overlay-btn-start')); expect(playerState.currentTime).toBe(120); }); it('go-live seeks to finite seekable end', async () => { - const user = userEvent.setup(); render(); - await user.click(screen.getByTestId('seek-overlay-trigger')); - await user.click(screen.getByTestId('seek-overlay-btn-live')); + await revealOverlay(); + fireEvent.click(screen.getByTestId('seek-overlay-btn-live')); expect(playerState.currentTime).toBe(5100); expect(Number.isFinite(playerState.currentTime)).toBe(true); }); diff --git a/docs/DVR_TESTING_GUIDE.md b/docs/DVR_TESTING_GUIDE.md index e371fdd1..4c345446 100644 --- a/docs/DVR_TESTING_GUIDE.md +++ b/docs/DVR_TESTING_GUIDE.md @@ -121,7 +121,7 @@ docker-compose up -d **Production:** ```bash # Check Railway database is accessible -psql "postgresql://postgres:yrCdfWDvdeHwLfEvGGuKgLWjxASIMoZV@gondola.proxy.rlwy.net:42430/railway" -c "SELECT 1;" +psql "$DATABASE_URL" -c "SELECT 1;" ``` ### **Build Failed** diff --git a/docs/ENV_SETUP_GUIDE.md b/docs/ENV_SETUP_GUIDE.md index ade34d29..e2affc2c 100644 --- a/docs/ENV_SETUP_GUIDE.md +++ b/docs/ENV_SETUP_GUIDE.md @@ -26,12 +26,12 @@ Edit `apps/api/.env` and `apps/web/.env.local` with your Square sandbox values: # In apps/api/.env AND apps/web/.env.local: # Square Sandbox (you already have these!) -SQUARE_ACCESS_TOKEN="EAAAl9Mn5O6Tx4lwa6GmnZZ231tv_uIw8DFz7AqvUjMr5LIUclId9TUzL83eIMt8" +SQUARE_ACCESS_TOKEN="your_square_sandbox_access_token" SQUARE_LOCATION_ID="LJWT6C2KX3YZV" SQUARE_ENVIRONMENT="sandbox" # Square Application ID (for frontend SDK) -NEXT_PUBLIC_SQUARE_APPLICATION_ID="sandbox-sq0idb-0H2RD-9_GtVX2NUU2Do6lw" +NEXT_PUBLIC_SQUARE_APPLICATION_ID="your_square_sandbox_application_id" NEXT_PUBLIC_SQUARE_LOCATION_ID="LJWT6C2KX3YZV" NEXT_PUBLIC_SQUARE_ENVIRONMENT="sandbox" @@ -40,7 +40,7 @@ NEXT_PUBLIC_SQUARE_ENVIRONMENT="sandbox" SQUARE_WEBHOOK_SIGNATURE_KEY="your_webhook_signature_key_here" # Square OAuth (for owner onboarding - optional for now) -SQUARE_APPLICATION_ID="sandbox-sq0idb-0H2RD-9_GtVX2NUU2Do6lw" +SQUARE_APPLICATION_ID="your_square_sandbox_application_id" SQUARE_APPLICATION_SECRET="your_square_application_secret" SQUARE_REDIRECT_URI="http://localhost:4301/api/owners/square/callback" ``` diff --git a/scripts/_archive/test-dvr.sh b/scripts/_archive/test-dvr.sh index 1db11b51..5cdad4b2 100755 --- a/scripts/_archive/test-dvr.sh +++ b/scripts/_archive/test-dvr.sh @@ -21,7 +21,11 @@ ENV=${1:-local} if [ "$ENV" = "prod" ]; then echo -e "${BLUE}🧪 Testing DVR against PRODUCTION${NC}" - export DATABASE_URL="postgresql://postgres:yrCdfWDvdeHwLfEvGGuKgLWjxASIMoZV@gondola.proxy.rlwy.net:42430/railway" + # Credentials come from the environment, never from source. + if [ -z "$DATABASE_URL" ]; then + echo "DATABASE_URL is not set. Export it first (see 1Password / railway variables)." + exit 1 + fi else echo -e "${BLUE}🧪 Testing DVR against LOCAL${NC}" export DATABASE_URL="postgresql://fieldview:dev_password_change_in_production@localhost:4302/fieldview_dev" diff --git a/scripts/apply-prod-migration.ts b/scripts/apply-prod-migration.ts index acf260c6..7daf20bc 100644 --- a/scripts/apply-prod-migration.ts +++ b/scripts/apply-prod-migration.ts @@ -1,11 +1,16 @@ import { PrismaClient } from '@prisma/client'; +// Credentials come from the environment, never from source. Get the value from 1Password: +// export DATABASE_URL=$(op read "op://Automation//password" ...) +// or from Railway: railway variables --service Postgres +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + console.error('DATABASE_URL is not set. Refusing to run against an unknown database.'); + process.exit(1); +} + const prisma = new PrismaClient({ - datasources: { - db: { - url: 'postgresql://postgres:yrCdfWDvdeHwLfEvqGuKgLWjxASIMoZV@gondola.proxy.rlwy.net:42430/railway', - }, - }, + datasources: { db: { url: databaseUrl } }, }); async function main() {