From 302b7d0367870e9c0191dc0ad80d4938a3d66e78 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 22:16:08 -0400 Subject: [PATCH 01/12] Retire unused activity file endpoints --- .../routers/__tests__/activity-files.test.ts | 176 --------- packages/api/src/routers/activity-files.ts | 342 +----------------- 2 files changed, 1 insertion(+), 517 deletions(-) diff --git a/packages/api/src/routers/__tests__/activity-files.test.ts b/packages/api/src/routers/__tests__/activity-files.test.ts index 6c6f4b78..5509d1be 100644 --- a/packages/api/src/routers/__tests__/activity-files.test.ts +++ b/packages/api/src/routers/__tests__/activity-files.test.ts @@ -422,55 +422,6 @@ describe("activityFilesRouter", () => { expect(mocks.storage.remove).not.toHaveBeenCalled(); }); - it("uploads activity file bytes to storage", async () => { - const caller = createCaller(); - const result = await caller.uploadActivityFile({ - fileName: "ride.fit", - fileSize: 4, - fileType: "ride.fit", - fileData: Buffer.from("test").toString("base64"), - }); - - expect(mocks.storage.upload).toHaveBeenCalledTimes(1); - const [filePath, bytes, options] = mocks.storage.upload.mock.calls[0] ?? []; - expect(filePath).toMatch(/^11111111-1111-4111-8111-111111111111\//); - expect(bytes).toBeInstanceOf(Uint8Array); - expect(options).toMatchObject({ contentType: "application/octet-stream", upsert: false }); - expect(result).toMatchObject({ success: true, size: 4 }); - }); - - it("rejects activity uploads when decoded bytes do not match declared file size", async () => { - const caller = createCaller(); - - await expect( - caller.uploadActivityFile({ - fileName: "ride.fit", - fileSize: 3, - fileType: "ride.fit", - fileData: Buffer.from("test").toString("base64"), - }), - ).rejects.toThrow("Decoded file data size must match declared file size"); - - expect(mocks.storage.upload).not.toHaveBeenCalled(); - }); - - it("rejects activity uploads when decoded bytes exceed the upload limit", async () => { - const caller = createCaller(); - const decodedByteLength = 50 * 1024 * 1024 + 1; - const oversizedBase64 = `${"A".repeat(Math.floor(decodedByteLength / 3) * 4)}AA==`; - - await expect( - caller.uploadActivityFile({ - fileName: "ride.fit", - fileSize: 50 * 1024 * 1024, - fileType: "ride.fit", - fileData: oversizedBase64, - }), - ).rejects.toThrow("Decoded file data must be less than 50MB"); - - expect(mocks.storage.upload).not.toHaveBeenCalled(); - }); - it("rejects processing activity files owned by another user", async () => { const { db } = createDbMock(); const caller = createCaller({ db }); @@ -671,120 +622,6 @@ describe("activityFilesRouter", () => { ); }); - it("invokes the analyze-activity-file edge function", async () => { - const caller = createCaller(); - const activityId = "22222222-2222-4222-8222-222222222222"; - - const result = await caller.analyzeActivityFile({ - activityId, - filePath: "11111111-1111-4111-8111-111111111111/ride.fit", - bucketName: "activity-files", - }); - - expect(mocks.functionsInvoke).toHaveBeenCalledWith("analyze-activity-file", { - body: { - activityId, - bucketName: "activity-files", - filePath: "11111111-1111-4111-8111-111111111111/ride.fit", - }, - }); - expect(result).toEqual({ queued: true }); - }); - - it("rejects malformed analyze-activity-file responses", async () => { - mocks.functionsInvoke.mockResolvedValue({ data: { ok: true }, error: null }); - - const caller = createCaller(); - - await expect( - caller.analyzeActivityFile({ - activityId: "22222222-2222-4222-8222-222222222222", - filePath: "11111111-1111-4111-8111-111111111111/ride.fit", - bucketName: "activity-files", - }), - ).rejects.toThrow("Activity file analysis failed"); - }); - - it("returns serialized activity details for FIT processing status", async () => { - const activityId = "33333333-3333-4333-8333-333333333333"; - const createdAt = new Date("2026-01-01T12:00:00.000Z"); - const { db } = createDbMock({ - findFirstResults: [ - { - id: activityId, - name: "Imported Ride", - type: "bike", - started_at: createdAt, - }, - ], - }); - - const caller = createCaller({ db }); - const result = await caller.getActivityFileStatus({ activityId }); - - expect(result).toEqual({ - activity: { - id: activityId, - name: "Imported Ride", - started_at: createdAt.toISOString(), - type: "bike", - }, - filePath: null, - fileSize: null, - processingStatus: "pending", - updatedAt: null, - version: null, - }); - }); - - it("lists FIT-backed activities with a next cursor", async () => { - const firstCreatedAt = new Date("2026-02-01T12:00:00.000Z"); - const secondCreatedAt = new Date("2026-01-31T12:00:00.000Z"); - const { db } = createDbMock({ - selectResults: [ - [ - { - id: "44444444-4444-4444-8444-444444444444", - name: "Ride A", - type: "bike", - started_at: firstCreatedAt, - created_at: firstCreatedAt, - }, - { - id: "55555555-5555-4555-8555-555555555555", - name: "Ride B", - type: "bike", - started_at: secondCreatedAt, - created_at: secondCreatedAt, - }, - ], - ], - }); - - const caller = createCaller({ db }); - const result = await caller.listActivityFiles({ pageSize: 2 }); - - expect(result).toEqual({ - files: [ - { - id: "44444444-4444-4444-8444-444444444444", - name: "Ride A", - type: "bike", - started_at: firstCreatedAt.toISOString(), - created_at: firstCreatedAt.toISOString(), - }, - { - id: "55555555-5555-4555-8555-555555555555", - name: "Ride B", - type: "bike", - started_at: secondCreatedAt.toISOString(), - created_at: secondCreatedAt.toISOString(), - }, - ], - nextCursor: secondCreatedAt.toISOString(), - }); - }); - it("rejects download URLs for another user's activity file", async () => { const caller = createCaller(); @@ -816,19 +653,6 @@ describe("activityFilesRouter", () => { }); }); - it("deletes an owned activity file from storage", async () => { - const caller = createCaller(); - - const result = await caller.deleteActivityFile({ - filePath: "11111111-1111-4111-8111-111111111111/ride.fit", - }); - - expect(mocks.storage.remove).toHaveBeenCalledWith([ - "11111111-1111-4111-8111-111111111111/ride.fit", - ]); - expect(result).toEqual({ success: true }); - }); - it("requires activityId for stream access", async () => { const caller = createCaller(); diff --git a/packages/api/src/routers/activity-files.ts b/packages/api/src/routers/activity-files.ts index 8f043740..73d913e7 100644 --- a/packages/api/src/routers/activity-files.ts +++ b/packages/api/src/routers/activity-files.ts @@ -35,7 +35,7 @@ import { profileMetrics, } from "@repo/db"; import { TRPCError } from "@trpc/server"; -import { and, desc, eq, isNotNull, lt, lte } from "drizzle-orm"; +import { and, desc, eq, lte } from "drizzle-orm"; import { z } from "zod"; import { markFailed, @@ -78,46 +78,6 @@ const activityStoragePathSchema = z message: "File path must be a relative storage path", }); -const base64FileDataSchema = z - .string() - .min(1, "File data is required") - .refine((value) => isBase64FileData(value), { - message: "File data must be valid base64", - }); - -function isBase64FileData(value: string): boolean { - if (value.length % 4 !== 0) return false; - - const paddingStart = value.endsWith("==") - ? value.length - 2 - : value.endsWith("=") - ? value.length - 1 - : value.length; - - for (let index = 0; index < paddingStart; index++) { - const code = value.charCodeAt(index); - const isUppercase = code >= 65 && code <= 90; - const isLowercase = code >= 97 && code <= 122; - const isDigit = code >= 48 && code <= 57; - const isPlusOrSlash = code === 43 || code === 47; - - if (!(isUppercase || isLowercase || isDigit || isPlusOrSlash)) { - return false; - } - } - - for (let index = paddingStart; index < value.length; index++) { - if (value[index] !== "=") return false; - } - - return true; -} - -function getBase64DecodedByteLength(value: string): number { - const paddingLength = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; - return (value.length / 4) * 3 - paddingLength; -} - const blobLikeSchema = z .object({ size: z.number().finite().nonnegative(), @@ -189,12 +149,6 @@ const signedDownloadUrlDataSchema = z }) .passthrough(); -const analyzeActivityFileResponseSchema = z - .object({ - queued: z.boolean(), - }) - .passthrough(); - async function ensureActivityFilesBucketExists() { const { error } = await storageService.storage.createBucket(ACTIVITY_FILE_BUCKET, { public: false, @@ -209,49 +163,6 @@ async function ensureActivityFilesBucketExists() { } } -const uploadActivityFileInput = z - .object({ - fileName: activityFileNameSchema, - fileSize: z - .number() - .int("File size must be an integer") - .positive("File size must be greater than zero") - .max( - ACTIVITY_FILE_SIZE_LIMIT, - `File size must be less than ${ACTIVITY_FILE_SIZE_LIMIT / (1024 * 1024)}MB`, - ), - fileType: activityFileNameSchema, - fileData: base64FileDataSchema, - }) - .strict() - .superRefine(({ fileData, fileSize }, ctx) => { - const decodedByteLength = getBase64DecodedByteLength(fileData); - - if (decodedByteLength > ACTIVITY_FILE_SIZE_LIMIT) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["fileData"], - message: `Decoded file data must be less than ${ACTIVITY_FILE_SIZE_LIMIT / (1024 * 1024)}MB`, - }); - } - - if (decodedByteLength !== fileSize) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["fileData"], - message: "Decoded file data size must match declared file size", - }); - } - }); - -const analyzeActivityFileInput = z - .object({ - activityId: z.string().uuid(), - filePath: activityStoragePathSchema, - bucketName: z.literal(ACTIVITY_FILE_BUCKET).default(ACTIVITY_FILE_BUCKET), - }) - .strict(); - const manualHistoricalImportProvenanceSchema = z.object({ import_source: z.literal("manual_historical"), import_file_type: z.enum(["fit", "gpx", "tcx"]), @@ -1747,212 +1658,6 @@ export const activityFilesRouter = createTRPCRouter({ } }), - /** - * Upload an activity file to Supabase Storage - */ - uploadActivityFile: protectedProcedure - .input(uploadActivityFileInput) - .mutation(async ({ ctx, input }) => { - const { fileName, fileSize, fileData, fileType } = input; - const userId = ctx.session?.user?.id; - const supabase = storageService; - - if (!userId) { - throwUnauthorizedActivityFileAccess(); - } - - try { - // Validate file type again (double security) - if (fileType.toLowerCase() !== fileName.toLowerCase()) { - throw new Error("File type must match file name"); - } - - inferActivityFileType(fileName); - - // Convert base64 to buffer - const binaryString = atob(fileData); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - - // Create unique file path - const filePath = `${userId}/${Date.now()}-${fileName}`; - - await ensureActivityFilesBucketExists(); - - // Upload to storage - const { error } = await supabase.storage - .from(ACTIVITY_FILE_BUCKET) - .upload(filePath, bytes, { - contentType: "application/octet-stream", - upsert: false, - }); - - if (error) { - throw new Error(`Failed to upload activity file: ${error.message}`); - } - - return { - success: true, - filePath, - size: fileSize, - }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - logger.error("Activity file upload error", getErrorDetails(error)); - throw new Error(`Activity file upload failed: ${getErrorMessage(error)}`); - } - }), - - /** - * Trigger activity file analysis via edge function - */ - analyzeActivityFile: protectedProcedure - .input(analyzeActivityFileInput) - .mutation(async ({ ctx, input }) => { - const { activityId, filePath, bucketName } = input; - const userId = ctx.session?.user?.id; - const supabase = storageService; - - if (!userId) { - throwUnauthorizedActivityFileAccess(); - } - - try { - const { data, error } = await supabase.functions.invoke("analyze-activity-file", { - body: { - activityId, - filePath, - bucketName, - }, - }); - - if (error) { - throw new Error(`Edge function error: ${error.message}`); - } - - return analyzeActivityFileResponseSchema.parse(data); - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - logger.error("Activity file analysis error", getErrorDetails(error)); - throw new Error(`Activity file analysis failed: ${getErrorMessage(error)}`); - } - }), - - /** - * Get activity file processing status - */ - getActivityFileStatus: protectedProcedure - .input(z.object({ activityId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const { activityId } = input; - const userId = ctx.session?.user?.id; - const db = getRequiredDb(ctx); - - if (!userId) { - throwUnauthorizedActivityFileAccess(); - } - - // Note: This will work once the migration is applied - // For now, return a placeholder response - try { - const activity = await db.query.activities.findFirst({ - columns: { - id: true, - name: true, - type: true, - started_at: true, - }, - where: and(eq(activities.id, activityId), eq(activities.profile_id, userId)), - }); - - if (!activity) { - throw new Error("Failed to get activity"); - } - - return { - processingStatus: "pending", // Placeholder - filePath: null, // Placeholder - fileSize: null, // Placeholder - version: null, // Placeholder - updatedAt: null, // Placeholder - activity: serializeActivityDates(activity), // Basic activity info - }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - logger.error("Activity file status error", getErrorDetails(error)); - throw new Error(`Failed to get activity file status: ${getErrorMessage(error)}`); - } - }), - - /** - * List activity files for a user - */ - listActivityFiles: protectedProcedure - .input( - z.object({ - pageSize: z.number().min(1).max(100).default(20), - cursor: z.string().datetime({ offset: true }).optional(), - }), - ) - .query(async ({ ctx, input }) => { - const { pageSize, cursor } = input; - const userId = ctx.session?.user?.id; - const db = getRequiredDb(ctx); - - if (!userId) { - throwUnauthorizedActivityFileAccess(); - } - - try { - const conditions = [ - eq(activities.profile_id, userId), - isNotNull(activityImports.activity_file_path), - ]; - - if (cursor) { - conditions.push(lt(activities.created_at, new Date(cursor))); - } - - const data = await db - .select({ - id: activities.id, - name: activities.name, - type: activities.type, - started_at: activities.started_at, - created_at: activities.created_at, - }) - .from(activities) - .innerJoin(activityImports, eq(activities.id, activityImports.activity_id)) - .where(and(...conditions)) - .orderBy(desc(activities.created_at)) - .limit(pageSize); - - return { - files: data.map((file) => serializeActivityDates(file)), - nextCursor: - data.length === pageSize ? data[data.length - 1]?.created_at.toISOString() : null, - }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - logger.error("List activity files error", getErrorDetails(error)); - throw new Error(`Failed to list activity files: ${getErrorMessage(error)}`); - } - }), - /** * Get activity file download URL (presigned) */ @@ -2001,51 +1706,6 @@ export const activityFilesRouter = createTRPCRouter({ } }), - /** - * Delete an activity file from storage - */ - deleteActivityFile: protectedProcedure - .input( - z.object({ - filePath: activityStoragePathSchema, - }), - ) - .mutation(async ({ ctx, input }) => { - const { filePath } = input; - const userId = ctx.session?.user?.id; - const supabase = storageService; - - if (!userId) { - throwUnauthorizedActivityFileAccess(); - } - - try { - // Verify user owns this file - if (!isOwnedActivityFilePath(userId, filePath)) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Access denied: You can only delete your own files", - }); - } - - // Delete from storage - const { error } = await supabase.storage.from(ACTIVITY_FILE_BUCKET).remove([filePath]); - - if (error) { - throw new Error(`Failed to delete activity file: ${error.message}`); - } - - return { success: true }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - logger.error("Activity file deletion error", getErrorDetails(error)); - throw new Error(`Activity file deletion failed: ${getErrorMessage(error)}`); - } - }), - /** * Get parsed streams from an activity file * Used for visualizing activity data (charts, maps) without storing streams in DB From 1e0869079b26e5b8d810aa46629eeee6a25243b0 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 22:35:38 -0400 Subject: [PATCH 02/12] Retire unused profile helper endpoints --- .../src/routers/__tests__/profiles.test.ts | 98 +--------- packages/api/src/routers/profiles.ts | 174 +----------------- 2 files changed, 4 insertions(+), 268 deletions(-) diff --git a/packages/api/src/routers/__tests__/profiles.test.ts b/packages/api/src/routers/__tests__/profiles.test.ts index 9a2c8b23..1d74a26a 100644 --- a/packages/api/src/routers/__tests__/profiles.test.ts +++ b/packages/api/src/routers/__tests__/profiles.test.ts @@ -1,26 +1,13 @@ -import { activities, activityEfforts, profileMetrics, profiles } from "@repo/db"; +import { activityEfforts, profileMetrics, profiles } from "@repo/db"; import { describe, expect, it, vi } from "vitest"; -const analysisMocks = vi.hoisted(() => ({ - buildActivityDerivedSummaryMap: vi.fn(), - createActivityAnalysisStore: vi.fn(), -})); - -vi.mock("../../lib/activity-analysis", () => ({ - buildActivityDerivedSummaryMap: analysisMocks.buildActivityDerivedSummaryMap, -})); - -vi.mock("../../infrastructure/repositories", () => ({ - createActivityAnalysisStore: analysisMocks.createActivityAnalysisStore, -})); - vi.mock("../../utils/profile-estimation-state", () => ({ bumpProfileEstimationState: vi.fn(async () => undefined), })); import { profilesRouter } from "../profiles"; -type TableName = "activities" | "activityEfforts" | "profileMetrics" | "profiles"; +type TableName = "activityEfforts" | "profileMetrics" | "profiles"; type SelectPlan = Partial>>; @@ -36,7 +23,6 @@ function getTableName(table: unknown): TableName { if (table === profiles) return "profiles"; if (table === profileMetrics) return "profileMetrics"; if (table === activityEfforts) return "activityEfforts"; - if (table === activities) return "activities"; throw new Error(`Unhandled table: ${String(table)}`); } @@ -67,7 +53,6 @@ function createDbMock(plan: DbPlan = {}) { profiles: [...(plan.select?.profiles ?? [])], profileMetrics: [...(plan.select?.profileMetrics ?? [])], activityEfforts: [...(plan.select?.activityEfforts ?? [])], - activities: [...(plan.select?.activities ?? [])], } satisfies Record>; const executeQueue = [...(plan.execute ?? [])]; @@ -291,65 +276,6 @@ describe("profilesRouter", () => { expect(calls.executes).toHaveLength(1); }); - it("list returns public-safe rows and respects limit/cursor", async () => { - const { caller, calls } = createCaller({ - select: { - profiles: [[createProfileRow({ id: OTHER_USER_ID, username: "other-athlete", dob: null })]], - }, - }); - - const result = await caller.list({ username: "other", limit: 5, cursor: "index:10" }); - - expect(result.items).toEqual([ - expect.objectContaining({ - id: OTHER_USER_ID, - username: "other-athlete", - dob: null, - email: null, - ftp: null, - full_name: null, - threshold_hr: null, - weight_kg: null, - }), - ]); - expect(calls.selects).toContainEqual({ table: "profiles", limitArgs: [5], offsetArgs: [10] }); - }); - - it("getStats aggregates totals and derived TSS for the requested period", async () => { - analysisMocks.createActivityAnalysisStore.mockReturnValue({ kind: "store" }); - analysisMocks.buildActivityDerivedSummaryMap.mockResolvedValue( - new Map([ - ["activity-1", { tss: 45 }], - ["activity-2", { tss: 55 }], - ]), - ); - - const { caller } = createCaller({ - select: { - activities: [ - [ - { id: "activity-1", duration_seconds: 3600, distance_meters: 12000 }, - { id: "activity-2", duration_seconds: 1800, distance_meters: 8000 }, - ], - ], - }, - }); - - const result = await caller.getStats({ period: 14 }); - - expect(result).toEqual({ - totalActivities: 2, - totalDuration: 5400, - totalDistance: 20000, - totalTSS: 100, - avgDuration: 2700, - period: 14, - }); - expect(analysisMocks.buildActivityDerivedSummaryMap).toHaveBeenCalledWith( - expect.objectContaining({ profileId: SESSION_USER_ID, activities: expect.any(Array) }), - ); - }); - it("getZones calculates heart-rate, power, and pace thresholds from current metrics", async () => { const { caller } = createCaller({ select: { @@ -370,26 +296,6 @@ describe("profilesRouter", () => { expect(result.powerZones?.zone4).toEqual({ min: 266, max: 310 }); }); - it("updateZones replaces threshold values and returns the refreshed profile", async () => { - const { caller, calls } = createCaller({ - select: { - profiles: [[createProfileRow()]], - profileMetrics: [[{ value: "69.5" }], [{ value: "178" }]], - activityEfforts: [[{ value: 315.79 }], []], - }, - }); - - const result = await caller.updateZones({ threshold_hr: 178, ftp: 300 }); - - expect(result.threshold_hr).toBe(178); - expect(result.ftp).toBe(300); - expect(calls.deletes).toEqual(["activityEfforts"]); - expect(calls.inserts.map((entry) => entry.table)).toEqual([ - "profileMetrics", - "activityEfforts", - ]); - }); - it("get provisions a missing profile instead of returning NOT_FOUND", async () => { const { caller, calls } = createCaller({ select: { diff --git a/packages/api/src/routers/profiles.ts b/packages/api/src/routers/profiles.ts index 986649bd..eb8ec6b7 100644 --- a/packages/api/src/routers/profiles.ts +++ b/packages/api/src/routers/profiles.ts @@ -1,48 +1,17 @@ import { randomUUID } from "node:crypto"; import { profileQuickUpdateSchema } from "@repo/core"; -import { - activities, - activityEfforts, - type PublicProfilesRow, - profileMetrics, - profiles, -} from "@repo/db"; +import { activityEfforts, type PublicProfilesRow, profileMetrics, profiles } from "@repo/db"; import { TRPCError } from "@trpc/server"; -import { and, desc, eq, gte, isNull, lte, sql } from "drizzle-orm"; +import { and, desc, eq, gte, isNull, sql } from "drizzle-orm"; import { z } from "zod"; import { getRequiredDb } from "../db"; -import { createActivityAnalysisStore } from "../infrastructure/repositories"; -import { buildActivityDerivedSummaryMap } from "../lib/activity-analysis"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { buildIndexPageInfo, indexCursorSchema, parseIndexCursor } from "../utils/index-cursor"; import { bumpProfileEstimationState } from "../utils/profile-estimation-state"; import { redactPrivateProfileDetailFields, redactProfileListFields, } from "../utils/profile-privacy"; -const profileListFiltersSchema = z - .object({ - username: z.string().optional(), - limit: z.number().int().min(1).max(50).default(25), - cursor: indexCursorSchema.optional(), - direction: z.enum(["forward", "backward"]).optional(), - }) - .strict(); - -const profileStatsSchema = z - .object({ - period: z.number().min(1).max(365).default(30), - }) - .strict(); - -const trainingZonesUpdateSchema = z - .object({ - threshold_hr: z.number().int().positive().optional(), - ftp: z.number().int().positive().optional(), - }) - .strict(); - const uuidSchema = z.string().uuid(); const nullableAvatarUrlSchema = z.string().nullable(); const nullableCoverUrlSchema = z.string().nullable(); @@ -598,101 +567,6 @@ export const profilesRouter = createTRPCRouter({ } }), - list: protectedProcedure.input(profileListFiltersSchema).query(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - void ctx; - const offset = parseIndexCursor(input.cursor); - - try { - const whereClause = input.username - ? sql`"profiles"."username" ilike ${`%${input.username}%`}` - : undefined; - const rows: ProfileBaseRow[] = whereClause - ? await db - .select(profileBaseSelect) - .from(profiles) - .where(whereClause) - .limit(input.limit) - .offset(offset) - : await db.select(profileBaseSelect).from(profiles).limit(input.limit).offset(offset); - const totalRows = whereClause - ? await db.select({ total: sql`count(*)::int` }).from(profiles).where(whereClause) - : await db.select({ total: sql`count(*)::int` }).from(profiles); - const total = Number(totalRows[0]?.total ?? 0); - - return { - items: rows.map((profile) => serializeProfileListItem(profile)), - total, - ...buildIndexPageInfo({ offset, limit: input.limit, total }), - }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch profiles", - }); - } - }), - - getStats: protectedProcedure.input(profileStatsSchema).query(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - - try { - const endDate = new Date(); - const startDate = new Date(); - startDate.setDate(endDate.getDate() - input.period); - - const activityRows = await db - .select() - .from(activities) - .where( - and( - eq(activities.profile_id, ctx.session.user.id), - gte(activities.started_at, startDate), - lte(activities.started_at, endDate), - ), - ); - - const derivedMap = await buildActivityDerivedSummaryMap({ - store: createActivityAnalysisStore(db), - profileId: ctx.session.user.id, - activities: activityRows, - }); - - const totalActivities = activityRows.length; - const totalDuration = activityRows.reduce((sum, activity) => { - return sum + (activity.duration_seconds || 0); - }, 0); - const totalDistance = activityRows.reduce((sum, activity) => { - return sum + (activity.distance_meters || 0); - }, 0); - const totalTSS = activityRows.reduce((sum, activity) => { - return sum + (derivedMap.get(activity.id)?.tss || 0); - }, 0); - - return { - totalActivities, - totalDuration, - totalDistance, - totalTSS, - avgDuration: totalActivities > 0 ? totalDuration / totalActivities : 0, - period: input.period, - }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to get profile stats", - }); - } - }), - getZones: protectedProcedure.query(async ({ ctx }) => { const db = getRequiredDb(ctx); @@ -798,48 +672,4 @@ export const profilesRouter = createTRPCRouter({ }); } }), - - updateZones: protectedProcedure - .input(trainingZonesUpdateSchema) - .mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - - try { - await Promise.all([ - input.threshold_hr === undefined - ? Promise.resolve() - : syncProfileMetric(db, { - profileId: ctx.session.user.id, - metricType: "lthr", - value: input.threshold_hr, - }), - input.ftp === undefined - ? Promise.resolve() - : syncManualFtp(db, { - profileId: ctx.session.user.id, - value: input.ftp, - }), - ]); - - const profile = await getSerializedProfile(db, ctx.session.user.id); - - if (!profile) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Profile not found", - }); - } - - return profile; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to update training zones", - }); - } - }), }); From 16df5f793c72dd0a2a93ccd9ef1a87b472a6f541 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 22:38:23 -0400 Subject: [PATCH 03/12] Retire unused profile and utility endpoints --- .../api/src/routers/__tests__/routes.test.ts | 33 ------------ .../api/src/routers/__tests__/social.test.ts | 11 ---- .../api/src/routers/__tests__/storage.test.ts | 34 ------------- packages/api/src/routers/routes.ts | 50 ------------------- packages/api/src/routers/social.ts | 38 -------------- packages/api/src/routers/storage.ts | 33 ------------ 6 files changed, 199 deletions(-) diff --git a/packages/api/src/routers/__tests__/routes.test.ts b/packages/api/src/routers/__tests__/routes.test.ts index 0d9fbed6..7b515300 100644 --- a/packages/api/src/routers/__tests__/routes.test.ts +++ b/packages/api/src/routers/__tests__/routes.test.ts @@ -522,37 +522,4 @@ describe("routesRouter", () => { expect(result).toEqual({ success: true }); expect(mockStorage.remove).toHaveBeenCalledWith([`${OWNER_ID}/route.gpx`]); }); - - it("updates owned route metadata and returns serialized timestamps", async () => { - const updatedRoute = createRouteRow({ - id: UPDATED_ROUTE_ID, - name: "Updated Route", - description: "Fresh description", - }); - const db = { - select: vi - .fn() - .mockImplementationOnce(() => createSelectWithLimit([{ id: UPDATED_ROUTE_ID }])), - update: vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(() => ({ - returning: vi.fn().mockResolvedValue([updatedRoute]), - })), - })), - })), - }; - - const caller = createCaller(db); - const result = await caller.update({ - id: UPDATED_ROUTE_ID, - name: "Updated Route", - description: "Fresh description", - }); - - expect(result).toEqual({ - ...updatedRoute, - created_at: "2026-02-01T10:00:00.000Z", - updated_at: "2026-02-02T10:00:00.000Z", - }); - }); }); diff --git a/packages/api/src/routers/__tests__/social.test.ts b/packages/api/src/routers/__tests__/social.test.ts index decb2306..57b1186d 100644 --- a/packages/api/src/routers/__tests__/social.test.ts +++ b/packages/api/src/routers/__tests__/social.test.ts @@ -420,17 +420,6 @@ describe("socialRouter", () => { }); }); - it("deleteComment allows owners to remove their comment", async () => { - const { caller, calls } = createCaller({ - execute: [[{ profile_id: SESSION_USER_ID }], []], - }); - - const result = await caller.deleteComment({ comment_id: COMMENT_ID }); - - expect(result).toEqual({ success: true }); - expect(calls.executes).toHaveLength(2); - }); - it("getComments returns serialized comments with nested profile data", async () => { const createdAt = new Date("2026-04-03T14:00:00.000Z"); const { caller } = createCaller({ diff --git a/packages/api/src/routers/__tests__/storage.test.ts b/packages/api/src/routers/__tests__/storage.test.ts index 3cb57aa4..f7900754 100644 --- a/packages/api/src/routers/__tests__/storage.test.ts +++ b/packages/api/src/routers/__tests__/storage.test.ts @@ -176,38 +176,4 @@ describe("storageRouter", () => { }), ).rejects.toMatchObject({ code: "FORBIDDEN" } as Partial); }); - - it("deletes a file owned by the current user", async () => { - const caller = createCaller(); - const filePath = "11111111-1111-4111-8111-111111111111/avatar.png"; - - const result = await caller.deleteFile({ filePath }); - - expect(result).toEqual({ success: true }); - expect(storageState.remove).toHaveBeenCalledWith([filePath]); - }); - - it("rejects deletion requests for another user's file", async () => { - const caller = createCaller(); - - await expect( - caller.deleteFile({ - filePath: "22222222-2222-4222-8222-222222222222/avatar.png", - }), - ).rejects.toMatchObject({ code: "FORBIDDEN" } as Partial); - - expect(storageState.remove).not.toHaveBeenCalled(); - }); - - it("rejects deletion requests for paths that only share a user id prefix", async () => { - const caller = createCaller(); - - await expect( - caller.deleteFile({ - filePath: "11111111-1111-4111-8111-111111111111-malicious/avatar.png", - }), - ).rejects.toMatchObject({ code: "FORBIDDEN" } as Partial); - - expect(storageState.remove).not.toHaveBeenCalled(); - }); }); diff --git a/packages/api/src/routers/routes.ts b/packages/api/src/routers/routes.ts index ccd1d611..939124bf 100644 --- a/packages/api/src/routers/routes.ts +++ b/packages/api/src/routers/routes.ts @@ -608,54 +608,4 @@ export const routesRouter = createTRPCRouter({ return deleteRouteOutputSchema.parse({ success: true }); }), - - // ------------------------------ - // Update route metadata - // ------------------------------ - update: protectedProcedure - .input( - z - .object({ - id: routeIdSchema, - name: z.string().min(1).max(100).optional(), - description: z.string().max(1000).optional(), - }) - .strict(), - ) - .output(serializedActivityRouteSchema) - .mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - const { id, ...updates } = input; - - const [existing] = await db - .select({ id: activityRoutes.id }) - .from(activityRoutes) - .where(and(eq(activityRoutes.id, id), eq(activityRoutes.profile_id, ctx.session.user.id))) - .limit(1); - - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Route not found or you don't have permission to edit it", - }); - } - - const [data] = await db - .update(activityRoutes) - .set({ - ...updates, - updated_at: new Date(), - }) - .where(and(eq(activityRoutes.id, id), eq(activityRoutes.profile_id, ctx.session.user.id))) - .returning(); - - if (!data) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to update route", - }); - } - - return serializeActivityRouteRow(data); - }), }); diff --git a/packages/api/src/routers/social.ts b/packages/api/src/routers/social.ts index a672db7c..ccd336eb 100644 --- a/packages/api/src/routers/social.ts +++ b/packages/api/src/routers/social.ts @@ -846,44 +846,6 @@ export const socialRouter = createTRPCRouter({ }; }), - deleteComment: protectedProcedure - .input(z.object({ comment_id: z.string().uuid() }).strict()) - .mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - - const commentResult = await db.execute(sql` - select profile_id - from comments - where id = ${input.comment_id}::uuid - limit 1 - `); - - const existingComment = commentResult.rows[0] - ? commentOwnerRowSchema.parse(commentResult.rows[0]) - : null; - - if (!existingComment) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Comment not found", - }); - } - - if (existingComment.profile_id !== ctx.session.user.id) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can only delete your own comments", - }); - } - - await db.execute(sql` - delete from comments - where id = ${input.comment_id}::uuid - `); - - return { success: true }; - }), - getComments: protectedProcedure .input( z diff --git a/packages/api/src/routers/storage.ts b/packages/api/src/routers/storage.ts index 2109e31f..3187578c 100644 --- a/packages/api/src/routers/storage.ts +++ b/packages/api/src/routers/storage.ts @@ -203,37 +203,4 @@ export const storageRouter = createTRPCRouter({ }); } }), - - deleteFile: protectedProcedure - .input( - z - .object({ - filePath: filePathSchema, - }) - .strict(), - ) - .mutation(async ({ ctx, input }) => { - try { - assertOwnedFilePath(ctx.session.user.id, input.filePath); - - const { error } = await storageService.storage.from(BUCKET_NAME).remove([input.filePath]); - - if (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to delete file: ${error.message}`, - }); - } - - return { success: true }; - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to delete file", - }); - } - }), }); From fdd66ea19bdffe942831818fe9d9ed3f9f009344 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 22:43:31 -0400 Subject: [PATCH 04/12] Retire unused analytics and coaching endpoints --- .../src/routers/__tests__/analytics.test.ts | 47 ----- .../src/routers/__tests__/coaching.test.ts | 136 +------------- packages/api/src/routers/analytics.ts | 49 +---- packages/api/src/routers/coaching.ts | 176 +----------------- 4 files changed, 8 insertions(+), 400 deletions(-) diff --git a/packages/api/src/routers/__tests__/analytics.test.ts b/packages/api/src/routers/__tests__/analytics.test.ts index 1e0b8a7e..de09c2f1 100644 --- a/packages/api/src/routers/__tests__/analytics.test.ts +++ b/packages/api/src/routers/__tests__/analytics.test.ts @@ -1,4 +1,3 @@ -import type { TRPCError } from "@trpc/server"; import { afterEach, describe, expect, it, vi } from "vitest"; import { analyticsRouter } from "../analytics"; @@ -134,50 +133,4 @@ describe("analyticsRouter", () => { expect.arrayContaining([OWNER_ID, "bike", "power", new Date("2026-03-04T12:00:00.000Z")]), ); }); - - it("predicts performance from the owned season-best curve", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-03T12:00:00.000Z")); - - const { caller } = createCaller([ - createEffortRow({ duration_seconds: 180, value: 250 + 15000 / 180 }), - createEffortRow({ duration_seconds: 300, value: 250 + 15000 / 300 }), - createEffortRow({ duration_seconds: 600, value: 250 + 15000 / 600 }), - createEffortRow({ duration_seconds: 1200, value: 250 + 15000 / 1200 }), - ]); - - const result = await caller.predictPerformance({ - activity_category: "bike", - effort_type: "power", - days: 90, - duration: 900, - }); - - expect(result).toMatchObject({ - predicted_value: 267, - unit: "watts", - model: { - cp: 250, - wPrime: 15000, - }, - }); - expect(result.model.error).toBeGreaterThan(0.99); - }); - - it("rejects performance prediction when the curve lacks enough valid durations", async () => { - const { caller } = createCaller([createEffortRow({ duration_seconds: 300, value: 300 })]); - - await expect( - caller.predictPerformance({ - activity_category: "bike", - effort_type: "power", - days: 90, - duration: 900, - }), - ).rejects.toMatchObject({ - code: "BAD_REQUEST", - message: - "Insufficient data to calculate performance model. Need at least 2 max efforts between 3 and 30 minutes.", - } as Partial); - }); }); diff --git a/packages/api/src/routers/__tests__/coaching.test.ts b/packages/api/src/routers/__tests__/coaching.test.ts index 4f0ef0c0..24c94396 100644 --- a/packages/api/src/routers/__tests__/coaching.test.ts +++ b/packages/api/src/routers/__tests__/coaching.test.ts @@ -1,20 +1,11 @@ -import { coachesAthletes, coachingInvitations } from "@repo/db"; +import { coachesAthletes } from "@repo/db"; import { describe, expect, it } from "vitest"; import { coachingRouter } from "../coaching"; const ATHLETE_ID = "11111111-1111-4111-8111-111111111111"; const COACH_ID = "22222222-2222-4222-8222-222222222222"; -const INVITATION_ID = "33333333-3333-4333-8333-333333333333"; type MockState = { - invitationRows?: Array<{ - athlete_id: string; - coach_id: string; - created_at: Date | string; - id: string; - status: "pending" | "accepted" | "declined"; - updated_at: Date | string; - }>; rosterRows?: Array<{ athlete_id: string; profile_id: string | null; @@ -22,26 +13,12 @@ type MockState = { profile_avatar_url: string | null; profile_username: string | null; }>; - coachRows?: Array<{ - coach_id: string; - profile_id: string | null; - profile_full_name: string | null; - profile_avatar_url: string | null; - profile_username: string | null; - }>; }; function createDbMock(state: MockState = {}) { - const insertCalls: Array<{ table: unknown; values: unknown }> = []; - const updateCalls: Array<{ table: unknown; values: unknown }> = []; - const resolveSelectRows = (table: unknown) => { - if (table === coachingInvitations) { - return state.invitationRows ?? []; - } - if (table === coachesAthletes) { - return state.rosterRows ?? state.coachRows ?? []; + return state.rosterRows ?? []; } return []; @@ -53,7 +30,7 @@ function createDbMock(state: MockState = {}) { const resolve = () => { if (table === coachesAthletes && joinedProfiles) { - return Promise.resolve(state.rosterRows ?? state.coachRows ?? []); + return Promise.resolve(state.rosterRows ?? []); } return Promise.resolve(resolveSelectRows(table)); @@ -82,38 +59,15 @@ function createDbMock(state: MockState = {}) { return builder; }; - const createUpdateBuilder = (table: unknown) => ({ - set: (values: unknown) => { - updateCalls.push({ table, values }); - - return { - where: async () => [], - }; - }, - }); - const db: any = { select: () => createSelectBuilder(), - insert: (table: unknown) => ({ - values: async (values: unknown) => { - insertCalls.push({ table, values }); - return []; - }, - }), - update: (table: unknown) => createUpdateBuilder(table), - transaction: async (callback: (tx: any) => Promise) => { - await callback({ - insert: db.insert, - update: db.update, - }); - }, }; - return { db, insertCalls, updateCalls }; + return { db }; } function createCaller(userId: string, state?: MockState) { - const { db, insertCalls, updateCalls } = createDbMock(state); + const { db } = createDbMock(state); return { caller: coachingRouter.createCaller({ @@ -123,66 +77,10 @@ function createCaller(userId: string, state?: MockState) { clientType: "test", trpcSource: "vitest", } as any), - insertCalls, - updateCalls, }; } describe("coachingRouter", () => { - it("creates a pending coaching invitation for the current user", async () => { - const { caller, insertCalls } = createCaller(COACH_ID); - - await expect( - caller.invite({ - athlete_id: ATHLETE_ID, - coach_id: COACH_ID, - }), - ).resolves.toEqual({ success: true }); - - expect(insertCalls).toContainEqual({ - table: coachingInvitations, - values: { - athlete_id: ATHLETE_ID, - coach_id: COACH_ID, - status: "pending", - }, - }); - }); - - it("accepts an invitation and creates the coach-athlete link", async () => { - const { caller, insertCalls, updateCalls } = createCaller(ATHLETE_ID, { - invitationRows: [ - { - id: INVITATION_ID, - athlete_id: ATHLETE_ID, - coach_id: COACH_ID, - status: "pending", - created_at: "2026-04-03T00:00:00.000Z", - updated_at: "2026-04-03T00:00:00.000Z", - }, - ], - }); - - await expect( - caller.respond({ - invitation_id: INVITATION_ID, - status: "accepted", - }), - ).resolves.toEqual({ success: true }); - - expect(updateCalls).toHaveLength(1); - expect(updateCalls[0]?.table).toBe(coachingInvitations); - expect(updateCalls[0]?.values).toMatchObject({ status: "accepted" }); - expect(updateCalls[0]?.values).toHaveProperty("updated_at"); - expect(insertCalls).toContainEqual({ - table: coachesAthletes, - values: { - coach_id: COACH_ID, - athlete_id: ATHLETE_ID, - }, - }); - }); - it("returns a normalized roster for the signed-in coach", async () => { const { caller } = createCaller(COACH_ID, { rosterRows: [ @@ -208,28 +106,4 @@ describe("coachingRouter", () => { }, ]); }); - - it("returns the athlete's coach with profile details", async () => { - const { caller } = createCaller(ATHLETE_ID, { - coachRows: [ - { - coach_id: COACH_ID, - profile_id: COACH_ID, - profile_full_name: "Coach Example", - profile_avatar_url: "https://example.com/coach.png", - profile_username: "coach-example", - }, - ], - }); - - await expect(caller.getCoach()).resolves.toEqual({ - coach_id: COACH_ID, - profiles: { - id: COACH_ID, - full_name: "Coach Example", - avatar_url: "https://example.com/coach.png", - username: "coach-example", - }, - }); - }); }); diff --git a/packages/api/src/routers/analytics.ts b/packages/api/src/routers/analytics.ts index b98a1bd7..567888b4 100644 --- a/packages/api/src/routers/analytics.ts +++ b/packages/api/src/routers/analytics.ts @@ -1,4 +1,4 @@ -import { calculateCriticalPower, calculateSeasonBestCurve } from "@repo/core/calculations"; +import { calculateSeasonBestCurve } from "@repo/core/calculations"; import { type BestEffort, BestEffortSchema } from "@repo/core/schemas/activity_efforts"; import { activityEfforts, @@ -6,7 +6,6 @@ import { publicActivityEffortsRowSchema, publicEffortTypeSchema, } from "@repo/db"; -import { TRPCError } from "@trpc/server"; import { and, eq, gte, isNotNull } from "drizzle-orm"; import { z } from "zod"; import { getRequiredDb } from "../db"; @@ -18,20 +17,6 @@ const analyticsInputSchema = z.object({ days: z.number().optional().default(90), }); -const predictPerformanceInputSchema = analyticsInputSchema.extend({ - duration: z.number().positive(), -}); - -const predictPerformanceOutputSchema = z.object({ - predicted_value: z.number(), - unit: z.string(), - model: z.object({ - cp: z.number(), - wPrime: z.number(), - error: z.number(), - }), -}); - async function getOwnedBestEfforts( db: ReturnType, input: z.infer, @@ -79,36 +64,4 @@ export const analyticsRouter = createTRPCRouter({ effort_type: input.effort_type, }); }), - - predictPerformance: protectedProcedure - .input(predictPerformanceInputSchema) - .output(predictPerformanceOutputSchema) - .query(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - const efforts = await getOwnedBestEfforts(db, input, ctx.session.user.id); - - const curve = calculateSeasonBestCurve(efforts, { - days: input.days, - activity_category: input.activity_category, - effort_type: input.effort_type, - }); - - const model = calculateCriticalPower(curve); - - if (!model) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "Insufficient data to calculate performance model. Need at least 2 max efforts between 3 and 30 minutes.", - }); - } - - const predictedValue = model.cp + model.wPrime * (1 / input.duration); - - return { - predicted_value: Math.round(predictedValue), - unit: input.effort_type === "power" ? "watts" : "m/s", - model, - }; - }), }); diff --git a/packages/api/src/routers/coaching.ts b/packages/api/src/routers/coaching.ts index cc62ffce..a1d4da6f 100644 --- a/packages/api/src/routers/coaching.ts +++ b/packages/api/src/routers/coaching.ts @@ -1,57 +1,10 @@ -import { - CreateCoachingInvitationSchema, - normalizeCoachRoster, - RespondToInvitationSchema, -} from "@repo/core"; -import { coachesAthletes, coachingInvitations, profiles } from "@repo/db"; +import { normalizeCoachRoster } from "@repo/core"; +import { coachesAthletes, profiles } from "@repo/db"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { getRequiredDb } from "../db"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -function toIsoString(value: string | Date) { - return value instanceof Date ? value.toISOString() : value; -} - -function serializeInvitationRow(row: { - athlete_id: string; - coach_id: string; - created_at: Date | string; - id: string; - status: "pending" | "accepted" | "declined"; - updated_at: Date | string; -}) { - return { - ...row, - created_at: toIsoString(row.created_at), - updated_at: toIsoString(row.updated_at), - }; -} - -async function getInvitationById(db: ReturnType, invitationId: string) { - const row = - ( - await db - .select({ - id: coachingInvitations.id, - athlete_id: coachingInvitations.athlete_id, - coach_id: coachingInvitations.coach_id, - status: coachingInvitations.status, - created_at: coachingInvitations.created_at, - updated_at: coachingInvitations.updated_at, - }) - .from(coachingInvitations) - .where(eq(coachingInvitations.id, invitationId)) - .limit(1) - )[0] ?? null; - - if (!row) { - return null; - } - - return serializeInvitationRow(row); -} - function toRosterEntry(row: { athlete_id: string; profile_avatar_url: string | null; @@ -72,103 +25,7 @@ function toRosterEntry(row: { }; } -function toCoachResult(row: { - coach_id: string; - profile_avatar_url: string | null; - profile_full_name: string | null; - profile_id: string | null; - profile_username: string | null; -}) { - return { - coach_id: row.coach_id, - profiles: row.profile_id - ? { - id: row.profile_id, - avatar_url: row.profile_avatar_url, - full_name: row.profile_full_name, - username: row.profile_username, - } - : null, - }; -} - export const coachingRouter = createTRPCRouter({ - invite: protectedProcedure - .input(CreateCoachingInvitationSchema) - .mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - - // Ensure user is either the coach or the athlete - if (ctx.session.user.id !== input.coach_id && ctx.session.user.id !== input.athlete_id) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can only invite for yourself", - }); - } - - try { - await db.insert(coachingInvitations).values({ - athlete_id: input.athlete_id, - coach_id: input.coach_id, - status: "pending", - }); - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: error instanceof Error ? error.message : "Failed to create coaching invitation", - }); - } - - return { success: true }; - }), - - respond: protectedProcedure.input(RespondToInvitationSchema).mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - - const invitation = await getInvitationById(db, input.invitation_id); - - if (!invitation) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Invitation not found", - }); - } - - if ( - ctx.session.user.id !== invitation.athlete_id && - ctx.session.user.id !== invitation.coach_id - ) { - throw new TRPCError({ code: "FORBIDDEN" }); - } - - try { - await db.transaction(async (tx) => { - await tx - .update(coachingInvitations) - .set({ - status: input.status, - updated_at: new Date(), - }) - .where(eq(coachingInvitations.id, input.invitation_id)); - - if (input.status === "accepted") { - await tx.insert(coachesAthletes).values({ - coach_id: invitation.coach_id, - athlete_id: invitation.athlete_id, - }); - } - }); - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - error instanceof Error ? error.message : "Failed to respond to coaching invitation", - }); - } - - return { success: true }; - }), - getRoster: protectedProcedure.query(async ({ ctx }) => { const db = getRequiredDb(ctx); @@ -193,33 +50,4 @@ export const coachingRouter = createTRPCRouter({ }); } }), - - getCoach: protectedProcedure.query(async ({ ctx }) => { - const db = getRequiredDb(ctx); - - try { - const row = - ( - await db - .select({ - coach_id: coachesAthletes.coach_id, - profile_id: profiles.id, - profile_full_name: profiles.full_name, - profile_avatar_url: profiles.avatar_url, - profile_username: profiles.username, - }) - .from(coachesAthletes) - .leftJoin(profiles, eq(profiles.id, coachesAthletes.coach_id)) - .where(eq(coachesAthletes.athlete_id, ctx.session.user.id)) - .limit(1) - )[0] ?? null; - - return row ? toCoachResult(row) : null; - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: error instanceof Error ? error.message : "Failed to load coach", - }); - } - }), }); From c48f21d57bf89cd48440c2024ab14325cd9c9530 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 22:51:54 -0400 Subject: [PATCH 05/12] Retire unused integration endpoints --- .../routers/__tests__/integrations.test.ts | 437 ------------ packages/api/src/routers/integrations.ts | 662 ------------------ 2 files changed, 1099 deletions(-) diff --git a/packages/api/src/routers/__tests__/integrations.test.ts b/packages/api/src/routers/__tests__/integrations.test.ts index 63ca5aed..fbaaa5af 100644 --- a/packages/api/src/routers/__tests__/integrations.test.ts +++ b/packages/api/src/routers/__tests__/integrations.test.ts @@ -4,8 +4,6 @@ import { z } from "zod"; const SESSION_USER_ID = "11111111-1111-4111-8111-111111111111"; const OTHER_USER_ID = "22222222-2222-4222-8222-222222222222"; -const EVENT_ID = "33333333-3333-4333-8333-333333333333"; -const FEED_ID = "44444444-4444-4444-8444-444444444444"; const STATE_ID = "55555555-5555-4555-8555-555555555555"; const SYNC_ID = "66666666-6666-4666-8666-666666666666"; @@ -14,15 +12,12 @@ const mocks = vi.hoisted(() => { integrations: { listByProfileId: vi.fn(), findByProfileIdAndProvider: vi.fn(), - findCredentialsByProfileIdAndProvider: vi.fn(), - updateTokensByProfileIdAndProvider: vi.fn(), deleteByProfileIdAndProvider: vi.fn(), upsertByProfileIdAndProvider: vi.fn(), }, oauthStates: { deleteExpired: vi.fn(), create: vi.fn(), - deleteCreatedBefore: vi.fn(), findValidByState: vi.fn(), deleteByState: vi.fn(), }, @@ -37,32 +32,7 @@ const mocks = vi.hoisted(() => { repositories, providerSyncRepository, createIntegrationsRepositories: vi.fn(() => repositories), - createIcalFeedRepository: vi.fn((input) => ({ kind: "ical-repository", input })), createProviderSyncRepository: vi.fn(() => providerSyncRepository), - createWahooRepository: vi.fn((input) => ({ kind: "wahoo-repository", input })), - createWahooRouteStorage: vi.fn((storage) => storage), - getApiStorageService: vi.fn(() => ({ - storage: { - from: vi.fn(() => ({ - download: vi.fn(), - })), - }, - })), - ical: { - instances: [] as Array<{ repository: unknown }>, - syncFeed: vi.fn(), - listFeeds: vi.fn(), - removeFeed: vi.fn(), - }, - wahoo: { - instances: [] as Array<{ deps: unknown }>, - jobInstances: [] as Array<{ deps: unknown }>, - enqueuePublishEvent: vi.fn(), - enqueueUnsyncEvent: vi.fn(), - syncEvent: vi.fn(), - unsyncEvent: vi.fn(), - getEventSyncStatus: vi.fn(), - }, setupRefresh: { refreshSetupData: vi.fn(), instances: [] as Array<{ deps: unknown }>, @@ -72,9 +42,7 @@ const mocks = vi.hoisted(() => { vi.mock("../../infrastructure/repositories", () => ({ createIntegrationsRepositories: mocks.createIntegrationsRepositories, - createIcalFeedRepository: mocks.createIcalFeedRepository, createProviderSyncRepository: mocks.createProviderSyncRepository, - createWahooRepository: mocks.createWahooRepository, })); vi.mock("../../application/onboarding-provider-enrichment", () => ({ @@ -112,92 +80,6 @@ vi.mock("@repo/db", () => { }; }); -vi.mock("../../storage-service", () => ({ - getApiStorageService: mocks.getApiStorageService, -})); - -vi.mock("../../lib/integrations/ical/sync-service", () => { - class MockIcalSyncError extends Error { - code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR"; - - constructor(message: string, code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR") { - super(message); - this.name = "IcalSyncError"; - this.code = code; - } - } - - class MockIcalSyncService { - repository: unknown; - - constructor(repository: unknown) { - this.repository = repository; - mocks.ical.instances.push({ repository }); - } - - syncFeed(...args: Parameters) { - return mocks.ical.syncFeed(...args); - } - - listFeeds(...args: Parameters) { - return mocks.ical.listFeeds(...args); - } - - removeFeed(...args: Parameters) { - return mocks.ical.removeFeed(...args); - } - } - - return { - IcalSyncError: MockIcalSyncError, - IcalSyncService: MockIcalSyncService, - }; -}); - -vi.mock("../../lib/integrations/wahoo/sync-service", () => ({ - createWahooRouteStorage: mocks.createWahooRouteStorage, - WahooSyncService: class MockWahooSyncService { - deps: unknown; - - constructor(deps: unknown) { - this.deps = deps; - mocks.wahoo.instances.push({ deps }); - } - - syncEvent(...args: Parameters) { - return mocks.wahoo.syncEvent(...args); - } - - unsyncEvent(...args: Parameters) { - return mocks.wahoo.unsyncEvent(...args); - } - - getEventSyncStatus(...args: Parameters) { - return mocks.wahoo.getEventSyncStatus(...args); - } - }, -})); - -vi.mock("../../lib/provider-sync/wahoo-job-service", () => ({ - WahooSyncJobService: class MockWahooSyncJobService { - deps: unknown; - - constructor(deps: unknown) { - this.deps = deps; - mocks.wahoo.jobInstances.push({ deps }); - } - - enqueuePublishEvent(...args: Parameters) { - return mocks.wahoo.enqueuePublishEvent(...args); - } - - enqueueUnsyncEvent(...args: Parameters) { - return mocks.wahoo.enqueueUnsyncEvent(...args); - } - }, -})); - -import { IcalSyncError } from "../../lib/integrations/ical/sync-service"; import { integrationsRouter } from "../integrations"; function createCaller(userId = SESSION_USER_ID) { @@ -215,9 +97,6 @@ const originalEnv = { ...process.env }; describe("integrationsRouter", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.ical.instances.length = 0; - mocks.wahoo.instances.length = 0; - mocks.wahoo.jobInstances.length = 0; mocks.setupRefresh.instances.length = 0; process.env.OAUTH_CALLBACK_BASE_URL = "https://app.example.com"; @@ -614,38 +493,6 @@ describe("integrationsRouter", () => { expect(mocks.providerSyncRepository.enqueueJob).not.toHaveBeenCalled(); }); - it("refreshSetupData delegates to the provider setup refresh service", async () => { - const caller = createCaller(); - mocks.setupRefresh.refreshSetupData.mockResolvedValue({ - fieldsFilled: ["dob", "gender", "weight_kg"], - fieldsKept: [], - fieldsUpdated: ["dob", "gender", "weight_kg", "ftp"], - keptExistingValues: true, - provider: "wahoo", - status: "succeeded", - }); - - await expect(caller.refreshSetupData({ provider: "wahoo" })).resolves.toEqual({ - fieldsFilled: ["dob", "gender", "weight_kg"], - fieldsKept: [], - fieldsUpdated: ["dob", "gender", "weight_kg", "ftp"], - keptExistingValues: true, - provider: "wahoo", - status: "succeeded", - }); - expect(mocks.setupRefresh.refreshSetupData).toHaveBeenCalledWith(SESSION_USER_ID, "wahoo"); - }); - - it("refreshSetupData maps missing integrations to not found", async () => { - const caller = createCaller(); - mocks.setupRefresh.refreshSetupData.mockRejectedValue(new Error("Integration not found")); - - await expect(caller.refreshSetupData({ provider: "wahoo" })).rejects.toMatchObject({ - code: "NOT_FOUND", - message: "Integration not found", - } satisfies Partial); - }); - it("getAuthUrl stores oauth state and builds the provider auth url", async () => { const caller = createCaller(); vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(STATE_ID); @@ -696,116 +543,6 @@ describe("integrationsRouter", () => { }); }); - it("refreshToken refreshes and persists new provider tokens", async () => { - const caller = createCaller(); - mocks.repositories.integrations.findByProfileIdAndProvider.mockResolvedValue({ - id: "77777777-7777-4777-8777-777777777777", - }); - mocks.repositories.integrations.findCredentialsByProfileIdAndProvider.mockResolvedValue({ - refresh_token: "refresh-1", - }); - mocks.repositories.integrations.updateTokensByProfileIdAndProvider.mockResolvedValue(undefined); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify({ - access_token: "access-2", - refresh_token: "refresh-2", - expires_in: 3600, - }), - { status: 200 }, - ), - ); - - await expect(caller.refreshToken({ provider: "strava" })).resolves.toEqual({ success: true }); - expect(mocks.repositories.integrations.findByProfileIdAndProvider).toHaveBeenCalledWith({ - profileId: SESSION_USER_ID, - provider: "strava", - }); - expect(mocks.repositories.integrations.updateTokensByProfileIdAndProvider).toHaveBeenCalledWith( - { - profileId: SESSION_USER_ID, - provider: "strava", - accessToken: "access-2", - refreshToken: "refresh-2", - expiresAt: expect.any(Date), - }, - ); - }); - - it("refreshToken tolerates extra provider token fields", async () => { - const caller = createCaller(); - mocks.repositories.integrations.findByProfileIdAndProvider.mockResolvedValue({ - id: "77777777-7777-4777-8777-777777777777", - }); - mocks.repositories.integrations.findCredentialsByProfileIdAndProvider.mockResolvedValue({ - refresh_token: "refresh-1", - }); - mocks.repositories.integrations.updateTokensByProfileIdAndProvider.mockResolvedValue(undefined); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify({ - access_token: "access-2", - refresh_token: "refresh-2", - expires_in: "3600", - token_type: "Bearer", - athlete: { id: 42 }, - }), - { status: 200 }, - ), - ); - - await expect(caller.refreshToken({ provider: "strava" })).resolves.toEqual({ success: true }); - expect(mocks.repositories.integrations.updateTokensByProfileIdAndProvider).toHaveBeenCalledWith( - { - profileId: SESSION_USER_ID, - provider: "strava", - accessToken: "access-2", - refreshToken: "refresh-2", - expiresAt: expect.any(Date), - }, - ); - }); - - it("refreshToken rejects malformed provider token payloads", async () => { - const caller = createCaller(); - mocks.repositories.integrations.findByProfileIdAndProvider.mockResolvedValue({ - id: "77777777-7777-4777-8777-777777777777", - }); - mocks.repositories.integrations.findCredentialsByProfileIdAndProvider.mockResolvedValue({ - refresh_token: "refresh-1", - }); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ refresh_token: "refresh-2" }), { status: 200 }), - ); - - await expect(caller.refreshToken({ provider: "strava" })).rejects.toMatchObject({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to refresh integration token", - } satisfies Partial); - expect( - mocks.repositories.integrations.updateTokensByProfileIdAndProvider, - ).not.toHaveBeenCalled(); - }); - - it("cleanupExpiredStates sums both cleanup strategies for the signed-in user", async () => { - const caller = createCaller(); - mocks.repositories.oauthStates.deleteExpired.mockResolvedValue(2); - mocks.repositories.oauthStates.deleteCreatedBefore.mockResolvedValue(3); - - await expect(caller.cleanupExpiredStates({ userId: OTHER_USER_ID })).resolves.toEqual({ - success: true, - cleaned: 5, - }); - expect(mocks.repositories.oauthStates.deleteExpired).toHaveBeenCalledWith({ - profileId: SESSION_USER_ID, - now: expect.any(Date), - }); - expect(mocks.repositories.oauthStates.deleteCreatedBefore).toHaveBeenCalledWith({ - profileId: SESSION_USER_ID, - before: expect.any(Date), - }); - }); - it("validateOAuthState returns serialized oauth state data", async () => { const caller = createCaller(); mocks.repositories.oauthStates.deleteExpired.mockResolvedValue(0); @@ -1065,178 +802,4 @@ describe("integrationsRouter", () => { await expect(caller.deleteOAuthState({ state: STATE_ID })).resolves.toEqual({ success: true }); expect(mocks.repositories.oauthStates.deleteByState).toHaveBeenCalledWith(STATE_ID); }); - - it("ical.addFeed syncs a new feed for the signed-in user", async () => { - const caller = createCaller(); - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(FEED_ID); - mocks.ical.syncFeed.mockResolvedValue({ - feed_id: FEED_ID, - feed_url: "https://example.com/calendar.ics", - imported: 2, - updated: 0, - removed: 0, - synced_at: "2026-04-01T10:00:00.000Z", - cache_tags: ["integrations.ical.feeds"], - }); - - await expect( - caller.ical.addFeed({ url: "https://example.com/calendar.ics" }), - ).resolves.toMatchObject({ feed_id: FEED_ID, imported: 2 }); - expect(mocks.ical.syncFeed).toHaveBeenCalledWith({ - profileId: SESSION_USER_ID, - feedId: FEED_ID, - feedUrl: "https://example.com/calendar.ics", - }); - }); - - it("ical.listFeeds lists feeds for the signed-in user", async () => { - const caller = createCaller(); - const feeds = [ - { - feed_id: FEED_ID, - feed_url: "https://example.com/calendar.ics", - event_count: 3, - last_event_updated_at: "2026-04-01T10:00:00.000Z", - }, - ]; - mocks.ical.listFeeds.mockResolvedValue(feeds); - - await expect(caller.ical.listFeeds({})).resolves.toEqual(feeds); - expect(mocks.ical.listFeeds).toHaveBeenCalledWith(SESSION_USER_ID); - }); - - it("ical.listFeeds rejects malformed service output", async () => { - const caller = createCaller(); - mocks.ical.listFeeds.mockResolvedValue([ - { - feed_id: FEED_ID, - feed_url: "https://example.com/calendar.ics", - event_count: "3", - last_event_updated_at: "2026-04-01T10:00:00.000Z", - }, - ]); - - await expect(caller.ical.listFeeds({})).rejects.toMatchObject({ - code: "INTERNAL_SERVER_ERROR", - message: "iCal sync service returned invalid feed list data", - } satisfies Partial); - }); - - it("ical.updateFeed maps sync errors to TRPC errors", async () => { - const caller = createCaller(); - mocks.ical.syncFeed.mockRejectedValue(new IcalSyncError("Invalid iCal feed", "BAD_REQUEST")); - - await expect( - caller.ical.updateFeed({ - feed_id: FEED_ID, - url: "https://example.com/updated.ics", - }), - ).rejects.toMatchObject({ - code: "BAD_REQUEST", - message: "Invalid iCal feed", - } satisfies Partial); - }); - - it("ical.removeFeed defaults purge_events to true", async () => { - const caller = createCaller(); - mocks.ical.removeFeed.mockResolvedValue({ - success: true, - removed_events: 4, - cache_tags: ["integrations.ical.feeds", "events.imported"], - }); - - await expect(caller.ical.removeFeed({ feed_id: FEED_ID })).resolves.toMatchObject({ - success: true, - removed_events: 4, - }); - expect(mocks.ical.removeFeed).toHaveBeenCalledWith({ - profileId: SESSION_USER_ID, - feedId: FEED_ID, - purgeEvents: true, - }); - }); - - it("wahoo.syncEvent enqueues a publish job", async () => { - const caller = createCaller(); - mocks.wahoo.enqueuePublishEvent.mockResolvedValue({ jobId: SYNC_ID, queued: true }); - - await expect(caller.wahoo.syncEvent({ eventId: EVENT_ID })).resolves.toEqual({ - jobId: SYNC_ID, - queued: true, - }); - expect(mocks.wahoo.enqueuePublishEvent).toHaveBeenCalledWith({ - eventId: EVENT_ID, - profileId: SESSION_USER_ID, - }); - }); - - it("wahoo.unsyncEvent enqueues an unsync job", async () => { - const caller = createCaller(); - mocks.wahoo.enqueueUnsyncEvent.mockResolvedValue({ jobId: SYNC_ID, queued: true }); - - await expect(caller.wahoo.unsyncEvent({ eventId: EVENT_ID })).resolves.toEqual({ - jobId: SYNC_ID, - queued: true, - }); - expect(mocks.wahoo.enqueueUnsyncEvent).toHaveBeenCalledWith({ - eventId: EVENT_ID, - profileId: SESSION_USER_ID, - }); - }); - - it("wahoo.getEventSyncStatus returns sync status details", async () => { - const caller = createCaller(); - mocks.wahoo.getEventSyncStatus.mockResolvedValue({ - id: SYNC_ID, - externalId: "activity-1", - updatedAt: new Date("2026-04-03T09:15:00.000Z"), - }); - - await expect(caller.wahoo.getEventSyncStatus({ eventId: EVENT_ID })).resolves.toEqual({ - synced: true, - provider: "wahoo", - externalId: "activity-1", - id: SYNC_ID, - updatedAt: "2026-04-03T09:15:00.000Z", - syncedAt: null, - }); - expect(mocks.wahoo.getEventSyncStatus).toHaveBeenCalledWith(EVENT_ID, SESSION_USER_ID); - }); - - it("wahoo.syncEvent rejects malformed enqueue results", async () => { - const caller = createCaller(); - mocks.wahoo.enqueuePublishEvent.mockResolvedValue({ - jobId: 42, - queued: true, - }); - - await expect(caller.wahoo.syncEvent({ eventId: EVENT_ID })).rejects.toMatchObject({ - code: "INTERNAL_SERVER_ERROR", - message: "Wahoo sync job service returned invalid enqueue data", - } satisfies Partial); - }); - - it("wahoo.testSync returns sync diagnostics with a timestamp", async () => { - const caller = createCaller(); - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-03T09:15:00.000Z")); - mocks.wahoo.syncEvent.mockResolvedValue({ - success: true, - action: "updated", - workoutId: "activity-9", - warnings: ["Route omitted"], - error: undefined, - }); - - await expect(caller.wahoo.testSync({ eventId: EVENT_ID })).resolves.toEqual({ - success: true, - action: "updated", - workoutId: "activity-9", - error: undefined, - warnings: ["Route omitted"], - timestamp: "2026-04-03T09:15:00.000Z", - }); - - vi.useRealTimers(); - }); }); diff --git a/packages/api/src/routers/integrations.ts b/packages/api/src/routers/integrations.ts index a8335171..de9f7d14 100644 --- a/packages/api/src/routers/integrations.ts +++ b/packages/api/src/routers/integrations.ts @@ -15,27 +15,14 @@ import { OnboardingProviderEnrichmentService } from "../application/onboarding-p import type { Context } from "../context"; import { getRequiredDb } from "../db"; import { - createIcalFeedRepository, createIntegrationsRepositories, createProviderSyncRepository, - createWahooRepository, } from "../infrastructure/repositories"; -import { IcalSyncError, IcalSyncService } from "../lib/integrations/ical/sync-service"; -import { createWahooRouteStorage, WahooSyncService } from "../lib/integrations/wahoo/sync-service"; import { logger } from "../lib/logger"; -import { WahooSyncJobService } from "../lib/provider-sync/wahoo-job-service"; -import { ROUTES_BUCKET } from "../lib/routes/route-file-helpers"; -import { getApiStorageService } from "../storage-service"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; -const storageService = getApiStorageService(); - const providerSchema = publicIntegrationProviderSchema; -const timestampStringSchema = z - .union([z.string(), z.date()]) - .transform((value) => (value instanceof Date ? value.toISOString() : value)); - const strictSuccessSchema = z.object({ success: z.literal(true) }).strict(); const activityHistoryResource = "historical_activities"; @@ -53,20 +40,6 @@ const authUrlResultSchema = z }) .strict(); -const cleanupExpiredStatesInputSchema = z - .object({ - userId: z.string().uuid().optional(), - }) - .strict() - .optional(); - -const cleanupExpiredStatesResultSchema = z - .object({ - success: z.literal(true), - cleaned: z.number().int().nonnegative(), - }) - .strict(); - const validateOAuthStateInputSchema = z .object({ state: z.string().uuid(), @@ -132,17 +105,6 @@ const syncNowResultSchema = z }) .strict(); -const refreshSetupDataResultSchema = z - .object({ - fieldsFilled: z.array(z.enum(["dob", "gender", "weight_kg", "ftp"])), - fieldsKept: z.array(z.enum(["dob", "gender", "weight_kg", "ftp"])), - fieldsUpdated: z.array(z.enum(["dob", "gender", "weight_kg", "ftp"])), - keptExistingValues: z.boolean(), - provider: providerSchema, - status: z.enum(["succeeded", "partial", "failed"]), - }) - .strict(); - const syncOverviewSchema = z.array( z .object({ @@ -187,96 +149,6 @@ const syncOverviewSchema = z.array( .strict(), ); -const icalSyncResultSchema = z - .object({ - feed_id: z.string().uuid(), - feed_url: z.string().url(), - imported: z.number().int().nonnegative(), - updated: z.number().int().nonnegative(), - removed: z.number().int().nonnegative(), - synced_at: z.string().datetime(), - cache_tags: z.array(z.string()), - }) - .strict(); - -const icalFeedListItemSchema = z - .object({ - feed_id: z.string().uuid(), - feed_url: z.string().url(), - event_count: z.number().int().nonnegative(), - last_event_updated_at: timestampStringSchema.nullable(), - }) - .strict(); - -const icalRemoveFeedResultSchema = z - .object({ - success: z.literal(true), - removed_events: z.number().int().nonnegative(), - cache_tags: z.array(z.string()), - }) - .strict(); - -const wahooSyncActionSchema = z.enum(["created", "updated", "recreated", "no_change"]); - -const wahooSyncResultSchema = z - .object({ - success: z.boolean(), - action: wahooSyncActionSchema, - workoutId: z.string().min(1).optional(), - warnings: z.array(z.string()).optional(), - error: z.string().min(1).optional(), - }) - .strict(); - -const wahooEventSyncStatusSchema = z.union([ - z - .object({ - synced: z.boolean(), - provider: providerSchema.optional(), - externalId: z.string().min(1).nullable().optional(), - id: z.string().min(1).optional(), - updatedAt: timestampStringSchema.nullable().optional(), - syncedAt: timestampStringSchema.nullable().optional(), - }) - .strict(), - z - .object({ - externalId: z.string().min(1), - id: z.string().min(1), - updatedAt: timestampStringSchema.nullable(), - }) - .strict(), - z.null(), -]); - -const wahooTestSyncResultSchema = z - .object({ - success: z.boolean(), - action: wahooSyncActionSchema, - workoutId: z.string().min(1).optional(), - error: z.string().min(1).optional(), - warnings: z.array(z.string()).optional(), - timestamp: z.string().datetime(), - }) - .strict(); - -const refreshTokenProviderResponseSchema = z - .object({ - access_token: z.string().min(1), - refresh_token: z.string().min(1).optional().nullable(), - expires_in: z - .union([ - z.number().int().nonnegative(), - z - .string() - .regex(/^\d+$/) - .transform((value) => Number(value)), - ]) - .optional() - .nullable(), - }) - .passthrough(); - function parseBoundaryValue(schema: z.ZodType, value: unknown, message: string): T { const parsed = schema.safeParse(value); @@ -291,49 +163,6 @@ function parseBoundaryValue(schema: z.ZodType, value: unknown, message: st return parsed.data; } -function normalizeWahooSyncResult(result: unknown, message: string) { - return parseBoundaryValue(wahooSyncResultSchema, result, message); -} - -function normalizeWahooEventSyncStatus(status: unknown) { - const parsed = parseBoundaryValue( - wahooEventSyncStatusSchema, - status, - "Wahoo sync status returned invalid data", - ); - - if (parsed === null) { - return { - synced: false, - provider: "wahoo" as const, - externalId: null, - id: undefined, - updatedAt: null, - syncedAt: null, - }; - } - - if ("synced" in parsed) { - return { - synced: parsed.synced, - provider: parsed.provider ?? "wahoo", - externalId: parsed.externalId ?? null, - id: parsed.id, - updatedAt: parsed.updatedAt ?? null, - syncedAt: parsed.syncedAt ?? null, - }; - } - - return { - synced: true, - provider: "wahoo" as const, - externalId: parsed.externalId, - id: parsed.id, - updatedAt: parsed.updatedAt, - syncedAt: null, - }; -} - async function readProviderSyncOverviewState( providerSyncRepository: ReturnType, integrationIds: string[], @@ -379,35 +208,6 @@ function getIntegrationsRepositories(ctx: Context) { return createIntegrationsRepositories(getRequiredDb(ctx)); } -function getIcalSyncService(ctx: Context) { - return new IcalSyncService( - createIcalFeedRepository({ - db: getRequiredDb(ctx), - }), - ); -} - -function getWahooSyncService(ctx: Context) { - return new WahooSyncService({ - repository: createWahooRepository({ db: getRequiredDb(ctx) }), - storage: createWahooRouteStorage({ - async downloadRouteGpx(filePath) { - const { data, error } = await storageService.storage.from(ROUTES_BUCKET).download(filePath); - if (error || !data) return null; - return data.text(); - }, - }), - }); -} - -function getWahooSyncJobService(ctx: Context) { - return new WahooSyncJobService({ - providerSyncRepository: createProviderSyncRepository({ db: getRequiredDb(ctx) }), - syncService: getWahooSyncService(ctx), - wahooRepository: createWahooRepository({ db: getRequiredDb(ctx) }), - }); -} - function supportsActivityHistorySync(provider: PublicIntegrationProvider): boolean { return ( providerHasCapability(provider, "activity_history_read") && @@ -499,13 +299,6 @@ async function refreshProviderSetupForSyncNow(input: { }; } -const wahooQueuedJobResultSchema = z - .object({ - jobId: z.string().uuid(), - queued: z.boolean(), - }) - .strict(); - const getAuthUrlInputSchema = z .object({ provider: providerSchema, @@ -519,12 +312,6 @@ const disconnectInputSchema = z }) .strict(); -const refreshTokenInputSchema = z - .object({ - provider: providerSchema, - }) - .strict(); - export const integrationsRouter = createTRPCRouter({ // List all integrations for current user list: protectedProcedure.query(async ({ ctx }) => { @@ -706,32 +493,6 @@ export const integrationsRouter = createTRPCRouter({ ); }), - refreshSetupData: protectedProcedure - .input(syncNowInputSchema) - .mutation(async ({ ctx, input }) => { - const service = new OnboardingProviderEnrichmentService({ db: getRequiredDb(ctx) }); - - try { - return parseBoundaryValue( - refreshSetupDataResultSchema, - await service.refreshSetupData(ctx.session.user.id, input.provider), - "Refresh setup data result was invalid", - ); - } catch (error) { - if (error instanceof TRPCError) throw error; - - if (error instanceof Error && error.message === "Integration not found") { - throw new TRPCError({ code: "NOT_FOUND", message: error.message }); - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: error instanceof Error ? error.message : "Failed to refresh setup data", - cause: error, - }); - } - }), - // Get OAuth authorization URL getAuthUrl: protectedProcedure.input(getAuthUrlInputSchema).mutation(async ({ ctx, input }) => { const repositories = getIntegrationsRepositories(ctx); @@ -780,87 +541,6 @@ export const integrationsRouter = createTRPCRouter({ return { success: true }; }), - // Refresh access token - refreshToken: protectedProcedure - .input(refreshTokenInputSchema) - .mutation(async ({ ctx, input }) => { - const repositories = getIntegrationsRepositories(ctx); - const integration = await repositories.integrations.findByProfileIdAndProvider({ - profileId: ctx.session.user.id, - provider: input.provider, - }); - - if (!integration) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Integration not found", - }); - } - - const credentials = await repositories.integrations.findCredentialsByProfileIdAndProvider({ - profileId: ctx.session.user.id, - provider: input.provider, - }); - - if (!credentials?.refresh_token) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "No refresh token available", - }); - } - - let newTokens: Awaited>; - try { - newTokens = await refreshProviderToken(input.provider, credentials.refresh_token); - } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to refresh integration token", - cause: error, - }); - } - - const updateData = { - access_token: newTokens.access_token, - refresh_token: newTokens.refresh_token, - expires_at: newTokens.expires_at, - }; - - await repositories.integrations.updateTokensByProfileIdAndProvider({ - profileId: ctx.session.user.id, - provider: input.provider, - accessToken: updateData.access_token, - refreshToken: updateData.refresh_token ?? null, - expiresAt: updateData.expires_at ? new Date(updateData.expires_at) : null, - }); - - return strictSuccessSchema.parse({ success: true }); - }), - - // Cleanup expired OAuth states for the signed-in user - cleanupExpiredStates: protectedProcedure - .input(cleanupExpiredStatesInputSchema) - .mutation(async ({ ctx }) => { - const repositories = getIntegrationsRepositories(ctx); - const expiredCount = await repositories.oauthStates.deleteExpired({ - profileId: ctx.session.user.id, - now: new Date(), - }); - const oldCount = await repositories.oauthStates.deleteCreatedBefore({ - profileId: ctx.session.user.id, - before: new Date(Date.now() - 24 * 60 * 60 * 1000), - }); - - return parseBoundaryValue( - cleanupExpiredStatesResultSchema, - { - success: true, - cleaned: expiredCount + oldCount, - }, - "OAuth state cleanup returned invalid data", - ); - }), - // Validate OAuth state and retrieve stored data validateOAuthState: publicProcedure .input(validateOAuthStateInputSchema) @@ -968,269 +648,6 @@ export const integrationsRouter = createTRPCRouter({ return strictSuccessSchema.parse({ success: true }); }), - - // ============================== - // iCal Feed Endpoints - // ============================== - - ical: createTRPCRouter({ - addFeed: protectedProcedure - .input( - z - .object({ - url: z.string().url(), - }) - .strict(), - ) - .mutation(async ({ ctx, input }) => { - const syncService = getIcalSyncService(ctx); - const feedId = crypto.randomUUID(); - - try { - return parseBoundaryValue( - icalSyncResultSchema, - await syncService.syncFeed({ - profileId: ctx.session.user.id, - feedId, - feedUrl: input.url, - }), - "iCal sync service returned invalid feed data", - ); - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - if (error instanceof IcalSyncError) { - throw new TRPCError({ - code: error.code, - message: error.message, - }); - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to add iCal feed", - }); - } - }), - - listFeeds: protectedProcedure.input(z.object({}).strict()).query(async ({ ctx }) => { - const syncService = getIcalSyncService(ctx); - - try { - return parseBoundaryValue( - z.array(icalFeedListItemSchema), - await syncService.listFeeds(ctx.session.user.id), - "iCal sync service returned invalid feed list data", - ); - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - if (error instanceof IcalSyncError) { - throw new TRPCError({ - code: error.code, - message: error.message, - }); - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to list iCal feeds", - }); - } - }), - - updateFeed: protectedProcedure - .input( - z - .object({ - feed_id: z.string().uuid(), - url: z.string().url(), - }) - .strict(), - ) - .mutation(async ({ ctx, input }) => { - const syncService = getIcalSyncService(ctx); - - try { - return parseBoundaryValue( - icalSyncResultSchema, - await syncService.syncFeed({ - profileId: ctx.session.user.id, - feedId: input.feed_id, - feedUrl: input.url, - }), - "iCal sync service returned invalid updated feed data", - ); - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - if (error instanceof IcalSyncError) { - throw new TRPCError({ - code: error.code, - message: error.message, - }); - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to update iCal feed", - }); - } - }), - - removeFeed: protectedProcedure - .input( - z - .object({ - feed_id: z.string().uuid(), - purge_events: z.boolean().optional().default(true), - }) - .strict(), - ) - .mutation(async ({ ctx, input }) => { - const syncService = getIcalSyncService(ctx); - - try { - return parseBoundaryValue( - icalRemoveFeedResultSchema, - await syncService.removeFeed({ - profileId: ctx.session.user.id, - feedId: input.feed_id, - purgeEvents: input.purge_events, - }), - "iCal sync service returned invalid remove-feed data", - ); - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - - if (error instanceof IcalSyncError) { - throw new TRPCError({ - code: error.code, - message: error.message, - }); - } - - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to remove iCal feed", - }); - } - }), - }), - - // ============================== - // Wahoo Sync Endpoints - // ============================== - - // Sync a planned activity event to Wahoo - wahoo: createTRPCRouter({ - syncEvent: protectedProcedure - .input( - z - .object({ - eventId: z.string().uuid(), - }) - .strict(), - ) - .mutation(async ({ ctx, input }) => { - const jobService = getWahooSyncJobService(ctx); - const result = parseBoundaryValue( - wahooQueuedJobResultSchema, - await jobService.enqueuePublishEvent({ - eventId: input.eventId, - profileId: ctx.session.user.id, - }), - "Wahoo sync job service returned invalid enqueue data", - ); - - return result; - }), - - // Unsync (remove) an event from Wahoo - unsyncEvent: protectedProcedure - .input( - z - .object({ - eventId: z.string().uuid(), - }) - .strict(), - ) - .mutation(async ({ ctx, input }) => { - const jobService = getWahooSyncJobService(ctx); - const result = parseBoundaryValue( - wahooQueuedJobResultSchema, - await jobService.enqueueUnsyncEvent({ - eventId: input.eventId, - profileId: ctx.session.user.id, - }), - "Wahoo sync job service returned invalid unsync enqueue data", - ); - - return result; - }), - - // Get sync status for an event - getEventSyncStatus: protectedProcedure - .input( - z - .object({ - eventId: z.string().uuid(), - }) - .strict(), - ) - .query(async ({ ctx, input }) => { - const syncService = getWahooSyncService(ctx); - const status = normalizeWahooEventSyncStatus( - await syncService.getEventSyncStatus(input.eventId, ctx.session.user.id), - ); - - return status; - }), - - // Test sync with detailed diagnostics - testSync: protectedProcedure - .input( - z - .object({ - eventId: z.string().uuid(), - }) - .strict(), - ) - .mutation(async ({ ctx, input }) => { - const syncService = getWahooSyncService(ctx); - - logger.debug("[Wahoo Test Sync] Starting test sync", { eventId: input.eventId }); - - const result = normalizeWahooSyncResult( - await syncService.syncEvent(input.eventId, ctx.session.user.id), - "Wahoo sync service returned invalid test-sync data", - ); - - logger.debug("[Wahoo Test Sync] Sync result", result); - - // Return detailed result including warnings - return parseBoundaryValue( - wahooTestSyncResultSchema, - { - success: result.success, - action: result.action, - workoutId: result.workoutId, - error: result.error, - warnings: result.warnings, - timestamp: new Date().toISOString(), - }, - "Wahoo test sync normalization returned invalid data", - ); - }), - }), }); // Helper functions (will be implemented in separate files) @@ -1309,82 +726,3 @@ function buildOAuthUrl( return `${config.authUrl}?${params.toString()}`; } - -async function refreshProviderToken( - provider: PublicIntegrationProvider, - refreshToken: string, -): Promise<{ - access_token: string; - refresh_token?: string; - expires_at?: string; -}> { - const configs = { - strava: { - tokenUrl: "https://www.strava.com/api/v3/oauth/token", - clientId: process.env.STRAVA_CLIENT_ID!, - clientSecret: process.env.STRAVA_CLIENT_SECRET!, - }, - wahoo: { - tokenUrl: "https://api.wahooligan.com/oauth/token", - clientId: process.env.WAHOO_CLIENT_ID!, - clientSecret: process.env.WAHOO_CLIENT_SECRET!, - }, - trainingpeaks: { - tokenUrl: "https://oauth.trainingpeaks.com/oauth/token", - clientId: process.env.TRAININGPEAKS_CLIENT_ID!, - clientSecret: process.env.TRAININGPEAKS_CLIENT_SECRET!, - }, - garmin: { - tokenUrl: "https://connectapi.garmin.com/oauth-service/oauth/access_token", - clientId: process.env.GARMIN_CLIENT_ID!, - clientSecret: process.env.GARMIN_CLIENT_SECRET!, - }, - zwift: { - tokenUrl: "https://secure.zwift.com/oauth/token", - clientId: process.env.ZWIFT_CLIENT_ID!, - clientSecret: process.env.ZWIFT_CLIENT_SECRET!, - }, - }; - - const config = configs[provider]; - if (!config) { - throw new Error(`Unknown provider: ${provider}`); - } - - const body = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - client_secret: config.clientSecret, - }); - - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - - if (!response.ok) { - throw new Error(`Failed to refresh token for ${provider}`); - } - - const data = (await response.json()) as { - access_token: string; - refresh_token?: string; - expires_in?: number | string | null; - }; - - const parsed = parseBoundaryValue( - refreshTokenProviderResponseSchema, - data, - `Token refresh response for ${provider} was invalid`, - ); - - return { - access_token: parsed.access_token, - refresh_token: parsed.refresh_token || refreshToken, - expires_at: parsed.expires_in - ? new Date(Date.now() + parsed.expires_in * 1000).toISOString() - : undefined, - }; -} From 2b106fe41bf19e354da6aff54adadc1655cf9080 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 22:59:07 -0400 Subject: [PATCH 06/12] Retire unused feed activity endpoint --- .../api/src/routers/__tests__/feed.test.ts | 260 ------------------ packages/api/src/routers/feed.ts | 215 +-------------- 2 files changed, 1 insertion(+), 474 deletions(-) diff --git a/packages/api/src/routers/__tests__/feed.test.ts b/packages/api/src/routers/__tests__/feed.test.ts index 3d71a6bd..c2ac5ca7 100644 --- a/packages/api/src/routers/__tests__/feed.test.ts +++ b/packages/api/src/routers/__tests__/feed.test.ts @@ -23,7 +23,6 @@ const ACTIVITY_ID_2 = "44444444-4444-4444-8444-444444444444"; type DbPlan = { execute?: Array>>; feedLikeRows?: Array<{ entity_id: string }>; - activityLikeRows?: Array<{ id: string }>; }; function createDbMock(plan: DbPlan = {}) { @@ -40,16 +39,6 @@ function createDbMock(plan: DbPlan = {}) { }; } - if (fields && "id" in fields) { - return { - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(() => Promise.resolve(plan.activityLikeRows ?? [])), - })), - })), - }; - } - throw new Error(`Unhandled select fields: ${Object.keys(fields ?? {}).join(",")}`); }), }; @@ -365,253 +354,4 @@ describe("feedRouter", () => { message: "Failed to fetch feed", }); }); - - it("getActivity returns detail data including likes and ordered comments", async () => { - const startedAt = new Date("2026-04-03T10:00:00.000Z"); - - const { caller } = createCaller({ - execute: [ - [ - { - id: ACTIVITY_ID, - profile_id: OWNER_ID, - name: "Morning Ride", - type: "ride", - notes: "Strong effort", - started_at: startedAt, - finished_at: new Date("2026-04-03T11:00:00.000Z"), - distance_meters: 32000, - duration_seconds: 3600, - moving_seconds: 3500, - avg_heart_rate: 150, - max_heart_rate: 178, - avg_power: 220, - max_power: 510, - avg_cadence: 88, - max_cadence: 105, - normalized_power: 240, - elevation_gain_meters: 450, - elevation_loss_meters: "445.75", - calories: 900, - polyline: null, - activity_file_path: null, - map_bounds: null, - likes_count: 4, - is_private: false, - created_at: new Date("2026-04-03T11:05:00.000Z"), - profile_username: "owner", - profile_avatar_url: "https://example.com/owner.png", - viewer_follows_owner: false, - }, - ], - [ - { - id: "66666666-6666-4666-8666-666666666666", - content: "Nice work!", - created_at: new Date("2026-04-03T12:00:00.000Z"), - profile_id: VIEWER_ID, - profile_username: "viewer", - profile_avatar_url: null, - }, - ], - ], - activityLikeRows: [{ id: "77777777-7777-4777-8777-777777777777" }], - }); - - const result = await caller.getActivity({ activityId: ACTIVITY_ID }); - - expect(result).toEqual({ - id: ACTIVITY_ID, - profile_id: OWNER_ID, - name: "Morning Ride", - type: "ride", - notes: "Strong effort", - started_at: startedAt.toISOString(), - finished_at: "2026-04-03T11:00:00.000Z", - distance_meters: 32000, - duration_seconds: 3600, - moving_seconds: 3500, - avg_heart_rate: 150, - max_heart_rate: 178, - avg_power: 220, - max_power: 510, - avg_cadence: 88, - max_cadence: 105, - normalized_power: 240, - elevation_gain_meters: 450, - elevation_loss_meters: 445.75, - calories: 900, - polyline: null, - activity_file_path: null, - map_bounds: null, - likes_count: 4, - is_private: false, - created_at: "2026-04-03T11:05:00.000Z", - profile: { - id: OWNER_ID, - username: "owner", - avatar_url: "https://example.com/owner.png", - }, - has_liked: true, - comments_count: 1, - comments: [ - { - id: "66666666-6666-4666-8666-666666666666", - content: "Nice work!", - created_at: "2026-04-03T12:00:00.000Z", - profile: { - id: VIEWER_ID, - username: "viewer", - avatar_url: null, - }, - }, - ], - }); - }); - - it("getActivity rejects private activities for non-followers", async () => { - const { caller } = createCaller({ - execute: [ - [ - { - id: ACTIVITY_ID, - profile_id: OWNER_ID, - name: "Private Ride", - type: "ride", - notes: null, - started_at: new Date("2026-04-03T10:00:00.000Z"), - finished_at: new Date("2026-04-03T11:00:00.000Z"), - distance_meters: 32000, - duration_seconds: 3600, - moving_seconds: 3500, - avg_heart_rate: null, - max_heart_rate: null, - avg_power: null, - max_power: null, - avg_cadence: null, - max_cadence: null, - normalized_power: null, - elevation_gain_meters: null, - elevation_loss_meters: null, - calories: null, - polyline: null, - activity_file_path: null, - map_bounds: null, - likes_count: 0, - is_private: true, - created_at: new Date("2026-04-03T11:05:00.000Z"), - profile_username: "owner", - profile_avatar_url: null, - viewer_follows_owner: false, - }, - ], - ], - }); - - await expect(caller.getActivity({ activityId: ACTIVITY_ID })).rejects.toMatchObject({ - code: "FORBIDDEN", - message: "You don't have permission to view this activity", - }); - }); - - it("getActivity rejects private activities for followers", async () => { - const { caller } = createCaller({ - execute: [ - [ - { - id: ACTIVITY_ID, - profile_id: OWNER_ID, - name: "Private Ride", - type: "ride", - notes: null, - started_at: new Date("2026-04-03T10:00:00.000Z"), - finished_at: new Date("2026-04-03T11:00:00.000Z"), - distance_meters: 32000, - duration_seconds: 3600, - moving_seconds: 3500, - avg_heart_rate: null, - max_heart_rate: null, - avg_power: null, - max_power: null, - avg_cadence: null, - max_cadence: null, - normalized_power: null, - elevation_gain_meters: null, - elevation_loss_meters: null, - calories: null, - polyline: null, - activity_file_path: null, - map_bounds: null, - likes_count: 0, - is_private: true, - created_at: new Date("2026-04-03T11:05:00.000Z"), - profile_username: "owner", - profile_avatar_url: null, - viewer_follows_owner: true, - }, - ], - ], - }); - - await expect(caller.getActivity({ activityId: ACTIVITY_ID })).rejects.toMatchObject({ - code: "FORBIDDEN", - message: "You don't have permission to view this activity", - }); - }); - - it("getActivity rejects malformed SQL comment rows", async () => { - const { caller } = createCaller({ - execute: [ - [ - { - id: ACTIVITY_ID, - profile_id: OWNER_ID, - name: "Morning Ride", - type: "ride", - notes: null, - started_at: new Date("2026-04-03T10:00:00.000Z"), - finished_at: new Date("2026-04-03T11:00:00.000Z"), - distance_meters: 32000, - duration_seconds: 3600, - moving_seconds: 3500, - avg_heart_rate: 150, - max_heart_rate: 178, - avg_power: 220, - max_power: 510, - avg_cadence: 88, - max_cadence: 105, - normalized_power: 240, - elevation_gain_meters: 450, - elevation_loss_meters: 445, - calories: 900, - polyline: null, - activity_file_path: null, - map_bounds: null, - likes_count: 4, - is_private: false, - created_at: new Date("2026-04-03T11:05:00.000Z"), - profile_username: "owner", - profile_avatar_url: "https://example.com/owner.png", - viewer_follows_owner: false, - }, - ], - [ - { - id: "bad-comment-id", - content: "Nice work!", - created_at: new Date("2026-04-03T12:00:00.000Z"), - profile_id: VIEWER_ID, - profile_username: "viewer", - profile_avatar_url: null, - }, - ], - ], - activityLikeRows: [{ id: "77777777-7777-4777-8777-777777777777" }], - }); - - await expect(caller.getActivity({ activityId: ACTIVITY_ID })).rejects.toMatchObject({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch activity", - }); - }); }); diff --git a/packages/api/src/routers/feed.ts b/packages/api/src/routers/feed.ts index b04a5f9e..b0542ddc 100644 --- a/packages/api/src/routers/feed.ts +++ b/packages/api/src/routers/feed.ts @@ -1,4 +1,4 @@ -import { publicActivitiesRowSchema, publicCommentsRowSchema, schema } from "@repo/db"; +import { publicActivitiesRowSchema, schema } from "@repo/db"; import { TRPCError } from "@trpc/server"; import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; @@ -76,34 +76,11 @@ const feedActivityRowSchema = publicActivitiesRowSchema ingestion_last_error_message: z.string().nullable().optional(), }); -const feedActivityDetailRowSchema = feedActivityRowSchema.extend({ - notes: publicActivitiesRowSchema.shape.notes, - max_power: nullableNumericSchema, - max_cadence: publicActivitiesRowSchema.shape.max_cadence, - normalized_power: nullableNumericSchema, - elevation_loss_meters: nullableNumericSchema, - map_bounds: publicActivitiesRowSchema.shape.map_bounds, - viewer_follows_owner: z.boolean(), -}); - const commentCountRowSchema = z.object({ entity_id: z.string().uuid(), comments_count: z.coerce.number().int().nonnegative(), }); -const activityCommentRowSchema = publicCommentsRowSchema - .pick({ - id: true, - content: true, - created_at: true, - }) - .extend({ - created_at: timestampSchema, - profile_id: z.string().uuid().nullable(), - profile_username: z.string().nullable(), - profile_avatar_url: z.string().nullable(), - }); - const feedActivityDtoSchema = z.object({ id: z.string().uuid(), profile_id: z.string().uuid(), @@ -138,27 +115,8 @@ const feedResponseSchema = z.object({ hasMore: z.boolean(), }); -const activityCommentDtoSchema = z.object({ - id: z.string().uuid(), - content: z.string(), - created_at: z.string(), - profile: feedProfileSchema.nullable(), -}); - -const feedActivityDetailDtoSchema = feedActivityDtoSchema.omit({ derived: true }).extend({ - notes: z.string().nullable(), - max_power: z.number().nullable(), - max_cadence: z.number().nullable(), - normalized_power: z.number().nullable(), - elevation_loss_meters: z.number().nullable(), - map_bounds: publicActivitiesRowSchema.shape.map_bounds, - comments_count: z.number().int().nonnegative(), - comments: z.array(activityCommentDtoSchema), -}); - export type FeedActivity = z.infer; type FeedActivityRow = z.infer; -type FeedActivityDetailRow = z.infer; function toIsoString(value: Date | string): string { return value instanceof Date ? value.toISOString() : value; @@ -396,175 +354,4 @@ export const feedRouter = createTRPCRouter({ }); } }), - - /** - * getActivity - Get a single activity for the feed detail view - * - * Authorization: - * - User must own the activity, OR - * - Activity must be public, OR - * - Activity must be public - */ - getActivity: protectedProcedure - .input(z.object({ activityId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const userId = ctx.session.user.id; - const db = getRequiredDb(ctx); - - try { - const activityResult = await db.execute(sql` - select - a.id, - a.profile_id, - a.name, - a.type, - a.notes, - a.started_at, - a.finished_at, - a.distance_meters, - a.duration_seconds, - a.moving_seconds, - a.avg_heart_rate, - a.max_heart_rate, - a.avg_power, - a.max_power, - a.avg_cadence, - a.max_cadence, - a.normalized_power, - a.elevation_gain_meters, - a.elevation_loss_meters, - a.calories, - a.polyline, - a.activity_file_path, - a.map_bounds, - a.likes_count, - a.is_private, - a.created_at, - p.username as profile_username, - p.avatar_url as profile_avatar_url, - exists ( - select 1 - from follows f - where f.follower_id = ${userId}::uuid - and f.following_id = a.profile_id - and f.status = 'accepted' - ) as viewer_follows_owner - from activities a - left join profiles p on p.id = a.profile_id - where a.id = ${input.activityId}::uuid - limit 1 - `); - - const activity = activityResult.rows[0] - ? feedActivityDetailRowSchema.parse(activityResult.rows[0]) - : null; - - if (!activity) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Activity not found", - }); - } - - const isActivityOwner = activity.profile_id === userId; - - if (activity.is_private && !isActivityOwner) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You don't have permission to view this activity", - }); - } - - const [likeRows, commentsResult] = await Promise.all([ - db - .select({ id: schema.likes.id }) - .from(schema.likes) - .where( - and( - eq(schema.likes.profile_id, userId), - eq(schema.likes.entity_id, input.activityId), - eq(schema.likes.entity_type, "activity"), - ), - ) - .limit(1), - db.execute(sql` - select - c.id, - c.content, - c.created_at, - p.id as profile_id, - p.username as profile_username, - p.avatar_url as profile_avatar_url - from comments c - left join profiles p on p.id = c.profile_id - where c.entity_id = ${input.activityId}::uuid - and c.entity_type = 'activity' - order by c.created_at asc - `), - ]); - - const comments = z.array(activityCommentDtoSchema).parse( - z - .array(activityCommentRowSchema) - .parse(commentsResult.rows) - .map((comment) => ({ - id: comment.id, - content: comment.content, - created_at: toIsoString(comment.created_at), - profile: comment.profile_id - ? { - id: comment.profile_id, - username: comment.profile_username, - avatar_url: comment.profile_avatar_url, - } - : null, - })), - ); - - return feedActivityDetailDtoSchema.parse({ - id: activity.id, - profile_id: activity.profile_id, - name: activity.name, - type: activity.type, - notes: activity.notes, - started_at: toIsoString(activity.started_at), - finished_at: toIsoString(activity.finished_at), - distance_meters: activity.distance_meters, - duration_seconds: activity.duration_seconds, - moving_seconds: activity.moving_seconds, - avg_heart_rate: activity.avg_heart_rate, - max_heart_rate: activity.max_heart_rate, - avg_power: activity.avg_power, - max_power: activity.max_power, - avg_cadence: activity.avg_cadence, - max_cadence: activity.max_cadence, - normalized_power: activity.normalized_power, - elevation_gain_meters: activity.elevation_gain_meters, - elevation_loss_meters: activity.elevation_loss_meters, - calories: activity.calories, - polyline: activity.polyline, - activity_file_path: activity.activity_file_path, - map_bounds: activity.map_bounds, - likes_count: activity.likes_count ?? 0, - is_private: activity.is_private, - created_at: toIsoString(activity.created_at), - profile: { - id: activity.profile_id, - username: activity.profile_username, - avatar_url: activity.profile_avatar_url, - }, - has_liked: likeRows.length > 0, - comments_count: comments.length, - comments, - }); - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Failed to fetch activity", - }); - } - }), }); From 1877da183e104738e1a75663a4561a417585fb0b Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 23:02:25 -0400 Subject: [PATCH 07/12] Retire unused activity plan import endpoints --- .../routers/__tests__/activity-plans.test.ts | 74 -------- packages/api/src/routers/activity-plans.ts | 178 ------------------ 2 files changed, 252 deletions(-) diff --git a/packages/api/src/routers/__tests__/activity-plans.test.ts b/packages/api/src/routers/__tests__/activity-plans.test.ts index 7458067d..b8146aa3 100644 --- a/packages/api/src/routers/__tests__/activity-plans.test.ts +++ b/packages/api/src/routers/__tests__/activity-plans.test.ts @@ -604,78 +604,4 @@ describe("activityPlansRouter", () => { } as any), ).rejects.toMatchObject({ code: "BAD_REQUEST" } as Partial); }); - - it("importFromFitTemplate updates an existing imported plan", async () => { - const existingRow = createActivityPlanRow({ - id: "12121212-1212-4212-8212-121212121212", - import_provider: "fit", - import_external_id: "fit-template-1", - }); - const updatedRow = createActivityPlanRow({ - id: existingRow.id, - name: "Updated FIT", - import_provider: "fit", - import_external_id: "fit-template-1", - }); - const { caller, callLog } = createCaller({ - state: { - "select:activity_plans": [[existingRow]], - "update:activity_plans": [[updatedRow]], - }, - }); - - const result = await caller.importFromFitTemplate({ - external_id: "fit-template-1", - name: "Updated FIT", - activity_category: "bike", - structure: sampleStructure, - }); - - const updateCall = callLog.find((call) => call.operation === "update"); - expect(updateCall?.payload).toMatchObject({ - name: "Updated FIT", - import_provider: "fit", - import_external_id: "fit-template-1", - template_visibility: "private", - }); - expect(result).toMatchObject({ - action: "updated", - item: { id: existingRow.id, content_type: "activity_plan" }, - }); - }); - - it("importFromZwoTemplate creates a new imported plan when none exists", async () => { - const createdRow = createActivityPlanRow({ - id: "13131313-1313-4313-8313-131313131313", - name: "Created ZWO", - import_provider: "zwo", - import_external_id: "zwo-template-1", - }); - const { caller, callLog } = createCaller({ - state: { - "select:activity_plans": [[]], - "insert:activity_plans": [[createdRow]], - }, - }); - - const result = await caller.importFromZwoTemplate({ - external_id: "zwo-template-1", - name: "Created ZWO", - activity_category: "bike", - structure: sampleStructure, - }); - - const insertCall = callLog.find((call) => call.operation === "insert"); - expect(insertCall?.payload).toMatchObject({ - name: "Created ZWO", - import_provider: "zwo", - import_external_id: "zwo-template-1", - template_visibility: "private", - profile_id: USER_ID, - }); - expect(result).toMatchObject({ - action: "created", - item: { id: createdRow.id, content_type: "activity_plan" }, - }); - }); }); diff --git a/packages/api/src/routers/activity-plans.ts b/packages/api/src/routers/activity-plans.ts index 497729c5..7861eec7 100644 --- a/packages/api/src/routers/activity-plans.ts +++ b/packages/api/src/routers/activity-plans.ts @@ -169,17 +169,6 @@ const updateActivityPlanWithIdInput = updateActivityPlanInput } }); -const importedTemplateInput = z - .object({ - external_id: z.string().min(1).max(255), - name: z.string().min(1, "Plan name is required"), - activity_category: publicActivityCategorySchema, - description: z.string().max(1000).nullable().optional(), - notes: z.string().max(2000).optional(), - structure: activityPlanStructureSchemaV2, - }) - .strict(); - function serializeActivityPlanRow(row: ActivityPlanRow | unknown) { return serializedActivityPlanSchema.parse(row); } @@ -893,171 +882,4 @@ export const activityPlansRouter = createTRPCRouter({ return withIdentityFields(planWithEstimation); }), - - importFromFitTemplate: protectedProcedure - .input(importedTemplateInput) - .mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - const estimationStore = getEstimationStore(ctx); - const provider = "fit"; - const externalId = input.external_id.trim(); - - const [existingRow] = await db - .select() - .from(activityPlans) - .where( - and( - eq(activityPlans.profile_id, ctx.session.user.id), - eq(activityPlans.import_provider, provider), - eq(activityPlans.import_external_id, externalId), - ), - ) - .limit(1); - - const payload: Partial = { - updated_at: new Date(), - name: input.name, - description: input.description?.trim() ? input.description.trim() : null, - notes: input.notes ?? null, - activity_category: input.activity_category, - structure: input.structure, - version: "1.0", - profile_id: ctx.session.user.id, - template_visibility: "private", - import_provider: provider, - import_external_id: externalId, - is_system_template: false, - is_public: false, - }; - - const [persistedRow] = existingRow - ? await db - .update(activityPlans) - .set(payload) - .where( - and( - eq(activityPlans.id, existingRow.id), - eq(activityPlans.profile_id, ctx.session.user.id), - ), - ) - .returning() - : await db - .insert(activityPlans) - .values({ - id: randomUUID(), - created_at: new Date(), - ...payload, - } as ActivityPlanInsert) - .returning(); - - if (!persistedRow) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Failed to import FIT template", - }); - } - - let withEstimation: SerializedActivityPlan | EstimatedActivityPlan = - serializeActivityPlanRow(persistedRow); - try { - withEstimation = await getActivityPlanDerivedMetrics( - serializeActivityPlanRow(persistedRow), - db, - estimationStore, - ctx.session.user.id, - ); - } catch (estimationError) { - console.warn( - "Failed to estimate activity import template; returning raw plan", - estimationError, - ); - } - - return { - action: existingRow ? "updated" : "created", - item: withIdentityFields(withEstimation), - }; - }), - - importFromZwoTemplate: protectedProcedure - .input(importedTemplateInput) - .mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - const estimationStore = getEstimationStore(ctx); - const provider = "zwo"; - const externalId = input.external_id.trim(); - - const [existingRow] = await db - .select() - .from(activityPlans) - .where( - and( - eq(activityPlans.profile_id, ctx.session.user.id), - eq(activityPlans.import_provider, provider), - eq(activityPlans.import_external_id, externalId), - ), - ) - .limit(1); - - const payload: Partial = { - updated_at: new Date(), - name: input.name, - description: input.description?.trim() ? input.description.trim() : null, - notes: input.notes ?? null, - activity_category: input.activity_category, - structure: input.structure, - version: "1.0", - profile_id: ctx.session.user.id, - template_visibility: "private", - import_provider: provider, - import_external_id: externalId, - is_system_template: false, - is_public: false, - }; - - const [persistedRow] = existingRow - ? await db - .update(activityPlans) - .set(payload) - .where( - and( - eq(activityPlans.id, existingRow.id), - eq(activityPlans.profile_id, ctx.session.user.id), - ), - ) - .returning() - : await db - .insert(activityPlans) - .values({ - id: randomUUID(), - created_at: new Date(), - ...payload, - } as ActivityPlanInsert) - .returning(); - - if (!persistedRow) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Failed to import ZWO template", - }); - } - - let withEstimation: SerializedActivityPlan | EstimatedActivityPlan = - serializeActivityPlanRow(persistedRow); - try { - withEstimation = await getActivityPlanDerivedMetrics( - serializeActivityPlanRow(persistedRow), - db, - estimationStore, - ctx.session.user.id, - ); - } catch (estimationError) { - console.warn("Failed to estimate ZWO import template; returning raw plan", estimationError); - } - - return { - action: existingRow ? "updated" : "created", - item: withIdentityFields(withEstimation), - }; - }), }); From 940b6bdb3709bdd6cf260069704a3feffcea3fd2 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 23:04:07 -0400 Subject: [PATCH 08/12] Remove dead activity plan import helper --- packages/api/src/routers/activity-plans.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/api/src/routers/activity-plans.ts b/packages/api/src/routers/activity-plans.ts index 7861eec7..74b36ca1 100644 --- a/packages/api/src/routers/activity-plans.ts +++ b/packages/api/src/routers/activity-plans.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; import { type ActivityTargetCategory, activityPlanCreateSchema, - activityPlanStructureSchemaV2, activityPlanUpdateSchema, getActivityTargetCompatibilityIssues, saveableActivityPlanStructureSchemaV2, @@ -133,10 +132,6 @@ function validateStructure(structure: unknown, activityCategory?: ActivityTarget } } -function getEstimationStore(ctx: Context) { - return createEventReadRepository(getRequiredDb(ctx)); -} - const createActivityPlanInput = activityPlanCreateSchema.safeExtend({ structure: saveableActivityPlanStructureSchemaV2, template_visibility: templateVisibilitySchema.optional(), From 8589d28f797b1298dfcb41c7e183009507bfb3d6 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Tue, 7 Jul 2026 23:04:43 -0400 Subject: [PATCH 09/12] Remove unused activity plan context import --- packages/api/src/routers/activity-plans.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/src/routers/activity-plans.ts b/packages/api/src/routers/activity-plans.ts index 74b36ca1..98ef5620 100644 --- a/packages/api/src/routers/activity-plans.ts +++ b/packages/api/src/routers/activity-plans.ts @@ -17,7 +17,6 @@ import { import { TRPCError } from "@trpc/server"; import { and, asc, count, desc, eq, gt, ilike, inArray, lt, or, sql } from "drizzle-orm"; import { z } from "zod"; -import type { Context } from "../context"; import { getRequiredDb } from "../db"; import { createEventReadRepository } from "../infrastructure/repositories"; import { createContentAccessPermissions } from "../permissions/content-access"; From 17c52bf3354757fdd505cefcb7cd3f0d675e0dd9 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Wed, 8 Jul 2026 08:53:10 -0400 Subject: [PATCH 10/12] Retire unused event helper endpoints --- .../api/src/routers/__tests__/events.test.ts | 478 ----------------- packages/api/src/routers/events.ts | 491 ------------------ 2 files changed, 969 deletions(-) diff --git a/packages/api/src/routers/__tests__/events.test.ts b/packages/api/src/routers/__tests__/events.test.ts index 73fb97ff..49102a68 100644 --- a/packages/api/src/routers/__tests__/events.test.ts +++ b/packages/api/src/routers/__tests__/events.test.ts @@ -283,41 +283,12 @@ function createCompletionRepository(params: { params.callLog.push({ table: "events", operation: "delete" }); return params.nextResult("events").data ?? []; }, - async getOwnedActivityForCompletion() { - return params.nextResult("activities").data ?? null; - }, async getOwnedEventForCompletion() { return params.nextResult("events").data ?? null; }, - async linkHistoricalCompletionIfEligible(input: { activityId: string }) { - params.callLog.push({ - table: "events", - operation: "update", - payload: { linked_activity_id: input.activityId, status: "completed" }, - }); - return params.nextResult("events").data ?? null; - }, - async listHistoricalActivitiesForReconciliation() { - return params.nextResult("activities").data ?? []; - }, - async listHistoricalEventsForReconciliation() { - params.logFilter("events", "is", "linked_activity_id", null); - return params.nextResult("events").data ?? []; - }, async listOwnedEventsForDeleteScope() { return params.nextResult("events").data ?? []; }, - async updateEventCompletionLink(input: { linkedActivityId: string | null; status: string }) { - params.callLog.push({ - table: "events", - operation: "update", - payload: { - linked_activity_id: input.linkedActivityId, - status: input.status, - }, - }); - return params.nextResult("events").data ?? null; - }, }; } @@ -507,44 +478,6 @@ describe("eventsRouter generalization", () => { }); }); - it("getWeekCount ignores legacy rest_day rows in the current UTC week", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-11T15:00:00.000Z")); - - const { caller, callLog } = createCaller({ - events: { - data: [ - createEventRow({ id: "week-visible-1", event_type: "planned_activity" }), - createEventRow({ id: "week-hidden-rest", event_type: "rest_day" }), - createEventRow({ id: "week-visible-2", event_type: "custom" }), - ], - error: null, - }, - }); - - const result = await caller.getWeekCount(); - - expect(result).toBe(2); - expect(callLog).toContainEqual({ - table: "events", - operation: "filter", - payload: { - type: "gte", - column: "starts_at", - value: "2026-03-08T00:00:00.000Z", - }, - }); - expect(callLog).toContainEqual({ - table: "events", - operation: "filter", - payload: { - type: "lt", - column: "starts_at", - value: "2026-03-15T00:00:00.000Z", - }, - }); - }); - it("create keeps legacy planned-activity input behavior", async () => { const { caller, callLog } = createCaller({ activity_plans: { @@ -1317,188 +1250,6 @@ describe("eventsRouter generalization", () => { expect(callLog).toEqual([]); }); - it("links an event to a completed activity", async () => { - const eventId = "00000000-0000-4000-8000-000000000050"; - const activityId = "11111111-1111-4111-8111-111111111150"; - - const { caller, callLog } = createCaller({ - events: [ - { - data: createEventRow({ - id: eventId, - status: "scheduled", - linked_activity_id: null, - }), - error: null, - }, - { - data: createEventRow({ - id: eventId, - status: "completed", - linked_activity_id: activityId, - }), - error: null, - }, - ], - activities: { - data: { id: activityId }, - error: null, - }, - }); - - const result = await caller.linkCompletion({ - event_id: eventId, - activity_id: activityId, - }); - - const updateCall = callLog.find( - (call) => call.table === "events" && call.operation === "update", - ); - - expect((updateCall?.payload as any).linked_activity_id).toBe(activityId); - expect((updateCall?.payload as any).status).toBe("completed"); - expect(result.linked_activity_id).toBe(activityId); - }); - - it("blocks completion linking for legacy rest_day rows", async () => { - const { caller } = createCaller({ - events: { - data: createEventRow({ - id: "00000000-0000-4000-8000-000000000057", - event_type: "rest_day", - }), - error: null, - }, - }); - - await expect( - caller.linkCompletion({ - event_id: "00000000-0000-4000-8000-000000000057", - activity_id: "11111111-1111-4111-8111-111111111157", - }), - ).rejects.toThrow( - /Cannot update rest_day events; rest is inferred from dates without scheduled planned events/, - ); - }); - - it("unlinks a previously linked completion", async () => { - const eventId = "00000000-0000-4000-8000-000000000051"; - const { caller, callLog } = createCaller({ - events: [ - { - data: createEventRow({ - id: eventId, - status: "completed", - linked_activity_id: "11111111-1111-4111-8111-111111111151", - }), - error: null, - }, - { - data: createEventRow({ - id: eventId, - status: "scheduled", - linked_activity_id: null, - }), - error: null, - }, - ], - }); - - const result = await caller.unlinkCompletion({ event_id: eventId }); - - const updateCall = callLog.find( - (call) => call.table === "events" && call.operation === "update", - ); - - expect((updateCall?.payload as any).linked_activity_id).toBeNull(); - expect((updateCall?.payload as any).status).toBe("scheduled"); - expect(result.linked_activity_id).toBeNull(); - }); - - it("blocks completion unlinking for legacy rest_day rows", async () => { - const { caller } = createCaller({ - events: { - data: createEventRow({ - id: "00000000-0000-4000-8000-000000000058", - event_type: "rest_day", - status: "completed", - linked_activity_id: "11111111-1111-4111-8111-111111111158", - }), - error: null, - }, - }); - - await expect( - caller.unlinkCompletion({ - event_id: "00000000-0000-4000-8000-000000000058", - }), - ).rejects.toThrow( - /Cannot update rest_day events; rest is inferred from dates without scheduled planned events/, - ); - }); - - it("linkCompletion returns not found when event does not exist", async () => { - const { caller } = createCaller({ - events: { data: null, error: { message: "missing" } }, - }); - - await expect( - caller.linkCompletion({ - event_id: "00000000-0000-4000-8000-000000000052", - activity_id: "11111111-1111-4111-8111-111111111152", - }), - ).rejects.toThrow("Event not found"); - }); - - it("unlinkCompletion returns not found when event does not exist", async () => { - const { caller } = createCaller({ - events: { data: null, error: { message: "missing" } }, - }); - - await expect( - caller.unlinkCompletion({ - event_id: "00000000-0000-4000-8000-000000000056", - }), - ).rejects.toThrow("Event not found"); - }); - - it("blocks completion linking for imported events", async () => { - const { caller } = createCaller({ - events: { - data: createEventRow({ - id: "00000000-0000-4000-8000-000000000053", - event_type: "imported", - }), - error: null, - }, - }); - - await expect( - caller.linkCompletion({ - event_id: "00000000-0000-4000-8000-000000000053", - activity_id: "11111111-1111-4111-8111-111111111153", - }), - ).rejects.toThrow("Imported events are read-only"); - }); - - it("blocks completion unlinking for imported events", async () => { - const { caller } = createCaller({ - events: { - data: createEventRow({ - id: "00000000-0000-4000-8000-000000000055", - event_type: "imported", - }), - error: null, - }, - }); - - await expect( - caller.unlinkCompletion({ - event_id: "00000000-0000-4000-8000-000000000055", - }), - ).rejects.toThrow("Imported events are read-only"); - }); - it("status resolution prefers explicit linkage when present", async () => { const { caller } = createCaller({ events: { @@ -1535,82 +1286,6 @@ describe("eventsRouter generalization", () => { expect(result.items.map((event) => event.id)).toEqual(["custom-visible"]); }); - it("listByWeek returns week events with lifecycle status", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-12T15:00:00.000Z")); - - const { caller, callLog } = createCaller({ - events: { - data: [ - createEventRow({ - id: "week-1", - event_type: "planned_activity", - activity_plan_id: "22222222-2222-4222-8222-222222222222", - starts_at: "2026-03-10T00:00:00.000Z", - }), - ], - error: null, - }, - activities: { - data: [ - { - id: "activity-week-1", - started_at: "2026-03-10T12:00:00.000Z", - activity_plan_id: "22222222-2222-4222-8222-222222222222", - }, - ], - error: null, - }, - }); - - const result = await caller.listByWeek({ - weekStart: "2026-03-08", - weekEnd: "2026-03-14", - }); - - expect(result).toHaveLength(1); - expect(result[0]?.status).toBe("completed"); - expect(callLog).toContainEqual({ - table: "events", - operation: "filter", - payload: { - type: "gte", - column: "starts_at", - value: "2026-03-08T00:00:00.000Z", - }, - }); - expect(callLog).toContainEqual({ - table: "events", - operation: "filter", - payload: { - type: "lt", - column: "starts_at", - value: "2026-03-15T00:00:00.000Z", - }, - }); - }); - - it("listByWeek filters legacy rest_day rows", async () => { - const { caller } = createCaller({ - events: { - data: [ - createEventRow({ id: "week-rest", event_type: "rest_day" }), - createEventRow({ id: "week-race", event_type: "race" }), - ], - error: null, - }, - activities: { data: [], error: null }, - }); - - const result = await caller.listByWeek({ - weekStart: "2026-03-08", - weekEnd: "2026-03-14", - }); - - expect(result.map((event) => event.id)).toEqual(["week-race"]); - expect(result[0]?.event_type).toBe("race_target"); - }); - it("list applies UTC-safe date boundaries for timestamp inputs", async () => { const { caller, callLog } = createCaller({ events: { data: [], error: null }, @@ -1861,157 +1536,4 @@ describe("eventsRouter generalization", () => { minimum: 5, }); }); - - it("reconcileHistoricalCompletions dry-run reports matches without persisting", async () => { - const eventId = "00000000-0000-4000-8000-000000000060"; - const activityId = "11111111-1111-4111-8111-111111111160"; - - const { caller, callLog } = createCaller({ - events: { - data: [ - createEventRow({ - id: eventId, - starts_at: "2026-01-05T00:00:00.000Z", - activity_plan_id: "22222222-2222-4222-8222-222222222222", - linked_activity_id: null, - status: "scheduled", - }), - ], - error: null, - }, - activities: { - data: [ - { - id: activityId, - started_at: "2026-01-05T18:00:00.000Z", - activity_plan_id: "22222222-2222-4222-8222-222222222222", - }, - ], - error: null, - }, - }); - - const result = await caller.reconcileHistoricalCompletions({ - date_from: "2026-01-01", - date_to: "2026-01-31", - limit: 50, - dry_run: true, - }); - - expect(result.counts).toEqual({ - scanned: 1, - matched: 1, - updated: 0, - skipped: 0, - }); - expect(result.sample_ids.matched_event_ids).toContain(eventId); - expect(result.sample_ids.updated_event_ids).toEqual([]); - expect(callLog.some((call) => call.operation === "update")).toBe(false); - }); - - it("reconcileHistoricalCompletions update mode writes linked_activity_id and completed status", async () => { - const eventId = "00000000-0000-4000-8000-000000000061"; - const activityId = "11111111-1111-4111-8111-111111111161"; - - const { caller, callLog } = createCaller({ - events: [ - { - data: [ - createEventRow({ - id: eventId, - starts_at: "2026-01-06T00:00:00.000Z", - activity_plan_id: "33333333-3333-4333-8333-333333333333", - linked_activity_id: null, - status: "scheduled", - }), - ], - error: null, - }, - { - data: { - id: eventId, - training_plan_id: null, - starts_at: "2026-01-06T00:00:00.000Z", - updated_at: "2026-01-06T20:00:00.000Z", - }, - error: null, - }, - ], - activities: { - data: [ - { - id: activityId, - started_at: "2026-01-06T20:00:00.000Z", - activity_plan_id: "33333333-3333-4333-8333-333333333333", - }, - ], - error: null, - }, - }); - - const result = await caller.reconcileHistoricalCompletions({ - date_from: "2026-01-01", - date_to: "2026-01-31", - limit: 50, - dry_run: false, - }); - - const updateCall = callLog.find( - (call) => call.table === "events" && call.operation === "update", - ); - - expect((updateCall?.payload as any).linked_activity_id).toBe(activityId); - expect((updateCall?.payload as any).status).toBe("completed"); - expect(result.counts).toEqual({ - scanned: 1, - matched: 1, - updated: 1, - skipped: 0, - }); - expect(result.sample_ids.updated_event_ids).toContain(eventId); - }); - - it("reconcileHistoricalCompletions leaves already-linked events untouched", async () => { - const { caller, callLog } = createCaller({ - events: { - data: [], - error: null, - }, - activities: { - data: [ - { - id: "11111111-1111-4111-8111-111111111163", - started_at: "2026-01-07T07:00:00.000Z", - activity_plan_id: null, - }, - ], - error: null, - }, - }); - - const result = await caller.reconcileHistoricalCompletions({ - date_from: "2026-01-01", - date_to: "2026-01-31", - limit: 50, - dry_run: false, - }); - - expect(result.counts).toEqual({ - scanned: 0, - matched: 0, - updated: 0, - skipped: 0, - }); - expect(callLog.some((call) => call.operation === "update")).toBe(false); - - const linkedFilter = callLog.some( - (call) => - call.table === "events" && - call.operation === "filter" && - (call.payload as any)?.type === "is" && - (call.payload as any)?.column === "linked_activity_id" && - (call.payload as any)?.value === null, - ); - expect(linkedFilter).toBe(true); - }); }); diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts index 57faa92c..9ab58ad4 100644 --- a/packages/api/src/routers/events.ts +++ b/packages/api/src/routers/events.ts @@ -9,7 +9,6 @@ import { plannedActivityUpdateSchema, } from "@repo/core"; import type { - ActivityRow, EventRow, PublicActivityPlansRow, PublicEventStatus, @@ -334,28 +333,6 @@ const eventDeleteInputSchema = z }) .strict(); -const eventLinkCompletionInputSchema = z - .object({ - event_id: z.string().uuid(), - activity_id: z.string().uuid(), - }) - .strict(); - -const eventUnlinkCompletionInputSchema = z - .object({ - event_id: z.string().uuid(), - }) - .strict(); - -const reconcileHistoricalCompletionsInputSchema = z - .object({ - date_from: z.string().optional(), - date_to: z.string().optional(), - limit: z.number().int().min(1).max(500).default(200), - dry_run: z.boolean().default(true), - }) - .strict(); - const eventListSchema = z .object({ event_types: z @@ -631,44 +608,6 @@ async function listVisibleOwnedEvents( }; } -async function countVisibleOwnedEventsInRange( - repository: ReturnType, - input: Pick< - Parameters["listOwnedEvents"]>[0], - "profileId" | "dateFrom" | "dateTo" - >, -): Promise { - let cursor: { startsAt: string; id: string } | undefined; - let count = 0; - const pageSize = 500; - - while (true) { - const batch = (await repository.listOwnedEvents({ - ...input, - includeAdhoc: true, - limit: pageSize, - cursor, - })) as PlannedEventRecord[] | null; - - const rows = batch ?? []; - if (rows.length === 0) break; - - count += rows.filter((row) => !isLegacyRestDayEvent(row)).length; - - if (rows.length < pageSize) break; - - const lastRow = rows[rows.length - 1]; - if (!lastRow) break; - - cursor = { - startsAt: toCanonicalInstantIso(lastRow.starts_at), - id: lastRow.id, - }; - } - - return count; -} - function countUniqueScheduledDates(events: Array>): number { return new Set(events.map((event) => toDateKey(event.starts_at))).size; } @@ -948,40 +887,6 @@ function _applyScopeFilters( return query.eq("series_id", seriesId); } -type ReconciliationEventCandidate = Pick< - EventRow, - "id" | "activity_plan_id" | "training_plan_id" | "status" | "linked_activity_id" -> & { - starts_at: string; - event_type: DbEventType; -}; - -type ReconciliationActivityCandidate = Pick & { - started_at: string; -}; - -function compareActivitiesForReconciliation( - a: ReconciliationActivityCandidate, - b: ReconciliationActivityCandidate, -): number { - const aStartedAt = typeof a.started_at === "string" ? a.started_at : ""; - const bStartedAt = typeof b.started_at === "string" ? b.started_at : ""; - const aId = typeof a.id === "string" ? a.id : ""; - const bId = typeof b.id === "string" ? b.id : ""; - const aMs = Date.parse(aStartedAt); - const bMs = Date.parse(bStartedAt); - - if (!Number.isNaN(aMs) && !Number.isNaN(bMs) && aMs !== bMs) { - return bMs - aMs; - } - - if (aStartedAt !== bStartedAt) { - return bStartedAt.localeCompare(aStartedAt); - } - - return aId.localeCompare(bId); -} - export const eventsRouter = createTRPCRouter({ getById: protectedProcedure .input(z.object({ id: z.string().uuid() })) @@ -1098,25 +1003,6 @@ export const eventsRouter = createTRPCRouter({ return events; }), - getWeekCount: protectedProcedure.query(async ({ ctx }) => { - const eventReadRepository = getEventReadRepository(ctx); - const now = new Date(); - const utcDay = now.getUTCDay(); - const startOfWeekUtc = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), - ); - startOfWeekUtc.setUTCDate(startOfWeekUtc.getUTCDate() - utcDay); - - const endOfWeekUtc = new Date(startOfWeekUtc); - endOfWeekUtc.setUTCDate(startOfWeekUtc.getUTCDate() + 7); - - return countVisibleOwnedEventsInRange(eventReadRepository, { - profileId: ctx.session.user.id, - dateFrom: startOfWeekUtc.toISOString(), - dateTo: endOfWeekUtc.toISOString(), - }); - }), - create: protectedProcedure.input(eventCreateInputSchema).mutation(async ({ ctx, input }) => { return createEventUseCase({ ctx, @@ -1546,331 +1432,6 @@ export const eventsRouter = createTRPCRouter({ }; }), - linkCompletion: protectedProcedure - .input(eventLinkCompletionInputSchema) - .mutation(async ({ ctx, input }) => { - const completionRepository = getEventCompletionRepository(ctx); - const existingEventRow = await completionRepository.getOwnedEventForCompletion({ - eventId: input.event_id, - profileId: ctx.session.user.id, - }); - - if (!existingEventRow) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Event not found", - }); - } - - const existingEvent = existingEventRow as PlannedEventRecord; - const existingEventType = toCoreEventType(existingEvent.event_type); - - assertRestDayWritesBlocked(existingEventType, "update"); - - if (existingEventType === "imported") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Imported events are read-only", - }); - } - - const activity = await completionRepository.getOwnedActivityForCompletion({ - activityId: input.activity_id, - profileId: ctx.session.user.id, - }); - - if (!activity) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Completed activity not found", - }); - } - - const _eventUpdates: Record = { - // TODO(events-router): Once dedicated completion lifecycle columns - // (completed_activity_id/completed_at) are available in the events - // table, migrate this canonical linkage to those fields. - linked_activity_id: input.activity_id, - status: "completed", - }; - - const linkedEventRow = await completionRepository.updateEventCompletionLink({ - eventId: input.event_id, - profileId: ctx.session.user.id, - linkedActivityId: input.activity_id, - status: "completed", - }); - - if (!linkedEventRow) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Failed to link completed activity", - }); - } - - const linkedEvent = mapEvent(linkedEventRow as PlannedEventRecord); - - return { - ...linkedEvent, - insight_refresh_hint: buildInsightRefreshHint({ - trainingPlanId: linkedEvent.training_plan_id, - changedDate: linkedEvent.scheduled_date, - changeAt: linkedEvent.updated_at, - }), - }; - }), - - unlinkCompletion: protectedProcedure - .input(eventUnlinkCompletionInputSchema) - .mutation(async ({ ctx, input }) => { - const completionRepository = getEventCompletionRepository(ctx); - const existingEventRow = await completionRepository.getOwnedEventForCompletion({ - eventId: input.event_id, - profileId: ctx.session.user.id, - }); - - if (!existingEventRow) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Event not found", - }); - } - - const existingEvent = existingEventRow as PlannedEventRecord; - const existingEventType = toCoreEventType(existingEvent.event_type); - - assertRestDayWritesBlocked(existingEventType, "update"); - - if (existingEventType === "imported") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Imported events are read-only", - }); - } - - const eventUpdates: Record = { - linked_activity_id: null, - status: existingEvent.status === "completed" ? "scheduled" : existingEvent.status, - }; - - const unlinkedEventRow = await completionRepository.updateEventCompletionLink({ - eventId: input.event_id, - profileId: ctx.session.user.id, - linkedActivityId: null, - status: eventUpdates.status as PublicEventStatus, - }); - - if (!unlinkedEventRow) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Failed to unlink completed activity", - }); - } - - const unlinkedEvent = mapEvent(unlinkedEventRow as PlannedEventRecord); - - return { - ...unlinkedEvent, - insight_refresh_hint: buildInsightRefreshHint({ - trainingPlanId: unlinkedEvent.training_plan_id, - changedDate: unlinkedEvent.scheduled_date, - changeAt: unlinkedEvent.updated_at, - }), - }; - }), - - reconcileHistoricalCompletions: protectedProcedure - .input(reconcileHistoricalCompletionsInputSchema) - .mutation(async ({ ctx, input }) => { - const completionRepository = getEventCompletionRepository(ctx); - const now = new Date(); - const todayDateKey = toDateKey(now.toISOString()); - - const defaultDateFrom = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), - ); - defaultDateFrom.setUTCDate(defaultDateFrom.getUTCDate() - 180); - const defaultDateFromKey = defaultDateFrom.toISOString().slice(0, 10); - - const dateFrom = input.date_from ? toDateKey(input.date_from) : defaultDateFromKey; - const dateTo = input.date_to ? toDateKey(input.date_to) : todayDateKey; - - const dateFromInclusiveIso = toDayStartIso(dateFrom); - const userRequestedToExclusiveIso = toNextDayStartIso(dateTo); - const historicalCutoffExclusiveIso = toDayStartIso(todayDateKey); - const dateToExclusiveIso = - userRequestedToExclusiveIso < historicalCutoffExclusiveIso - ? userRequestedToExclusiveIso - : historicalCutoffExclusiveIso; - - if (dateFromInclusiveIso >= dateToExclusiveIso) { - return { - dry_run: input.dry_run, - window: { - date_from: dateFrom, - date_to: dateTo, - applied_to_exclusive: dateToExclusiveIso, - }, - counts: { - scanned: 0, - matched: 0, - updated: 0, - skipped: 0, - }, - sample_ids: { - matched_event_ids: [], - matched_activity_ids: [], - updated_event_ids: [], - skipped_event_ids: [], - }, - cache_tags: [], - insight_refresh_hints: [], - }; - } - - const scannedEvents = await completionRepository.listHistoricalEventsForReconciliation({ - profileId: ctx.session.user.id, - dateFromInclusiveIso, - dateToExclusiveIso, - limit: input.limit, - }); - - if (scannedEvents.length === 0) { - return { - dry_run: input.dry_run, - window: { - date_from: dateFrom, - date_to: dateTo, - applied_to_exclusive: dateToExclusiveIso, - }, - counts: { - scanned: 0, - matched: 0, - updated: 0, - skipped: 0, - }, - sample_ids: { - matched_event_ids: [], - matched_activity_ids: [], - updated_event_ids: [], - skipped_event_ids: [], - }, - cache_tags: [], - insight_refresh_hints: [], - }; - } - - const activities = await completionRepository.listHistoricalActivitiesForReconciliation({ - profileId: ctx.session.user.id, - dateFromInclusiveIso, - dateToExclusiveIso, - }); - - const activitiesByDate = new Map(); - const activitiesByDateAndPlan = new Map(); - - for (const activity of activities) { - const dateKey = toDateKey(activity.started_at); - - const dateList = activitiesByDate.get(dateKey) ?? []; - dateList.push(activity); - activitiesByDate.set(dateKey, dateList); - - if (activity.activity_plan_id) { - const planSignature = buildCompletedSignature(dateKey, activity.activity_plan_id); - const byPlanList = activitiesByDateAndPlan.get(planSignature) ?? []; - byPlanList.push(activity); - activitiesByDateAndPlan.set(planSignature, byPlanList); - } - } - - for (const list of activitiesByDate.values()) { - list.sort(compareActivitiesForReconciliation); - } - for (const list of activitiesByDateAndPlan.values()) { - list.sort(compareActivitiesForReconciliation); - } - - const usedActivityIds = new Set(); - const matchedEventIds: string[] = []; - const matchedActivityIds: string[] = []; - const updatedEventIds: string[] = []; - const skippedEventIds: string[] = []; - const insightRefreshHints: InsightRefreshHint[] = []; - - for (const event of scannedEvents) { - const dateKey = toDateKey(event.starts_at); - const pool = event.activity_plan_id - ? activitiesByDateAndPlan.get(buildCompletedSignature(dateKey, event.activity_plan_id)) - : activitiesByDate.get(dateKey); - - const candidate = pool?.find((activity) => !usedActivityIds.has(activity.id)); - - if (!candidate) { - skippedEventIds.push(event.id); - continue; - } - - matchedEventIds.push(event.id); - matchedActivityIds.push(candidate.id); - usedActivityIds.add(candidate.id); - - if (input.dry_run) { - continue; - } - - const updatedEvent = await completionRepository.linkHistoricalCompletionIfEligible({ - activityId: candidate.id, - eventId: event.id, - profileId: ctx.session.user.id, - }); - - if (!updatedEvent) { - skippedEventIds.push(event.id); - continue; - } - - updatedEventIds.push(updatedEvent.id); - insightRefreshHints.push( - buildInsightRefreshHint({ - trainingPlanId: updatedEvent.training_plan_id, - changedDate: toDateKey(updatedEvent.starts_at), - changeAt: updatedEvent.updated_at, - }), - ); - } - - const uniqueInsightRefreshHints = Array.from( - new Map(insightRefreshHints.map((hint) => [hint.refresh_key, hint])).values(), - ); - - return { - dry_run: input.dry_run, - window: { - date_from: dateFrom, - date_to: dateTo, - applied_to_exclusive: dateToExclusiveIso, - }, - counts: { - scanned: scannedEvents.length, - matched: matchedEventIds.length, - updated: updatedEventIds.length, - skipped: skippedEventIds.length, - }, - sample_ids: { - matched_event_ids: matchedEventIds.slice(0, 20), - matched_activity_ids: matchedActivityIds.slice(0, 20), - updated_event_ids: updatedEventIds.slice(0, 20), - skipped_event_ids: skippedEventIds.slice(0, 20), - }, - cache_tags: - input.dry_run || updatedEventIds.length === 0 - ? [] - : ["events.list", "events.today", "events.weekCount", "events.byWeek"], - insight_refresh_hints: uniqueInsightRefreshHints, - }; - }), - list: protectedProcedure.input(eventListSchema).query(async ({ ctx, input }) => { const eventReadRepository = getEventReadRepository(ctx); const limit = input.limit; @@ -2174,56 +1735,4 @@ export const eventsRouter = createTRPCRouter({ restDaysStatus === "warning", }; }), - - listByWeek: protectedProcedure - .input( - z.object({ - weekStart: z.string(), - weekEnd: z.string(), - }), - ) - .query(async ({ ctx, input }) => { - const eventReadRepository = getEventReadRepository(ctx); - const data = await eventReadRepository.listOwnedEvents({ - profileId: ctx.session.user.id, - dateFrom: toDayStartIso(input.weekStart), - dateTo: toNextDayStartIso(input.weekEnd), - includeAdhoc: true, - limit: 500, - }); - - const events = mapEvents(data as PlannedEventRecord[] | null); - if (events.length > 0) { - const plans = events - .map((event) => event.activity_plan) - .filter((plan): plan is NonNullable => plan !== null); - - const plansWithEstimation = await getActivityPlansDerivedMetrics( - plans, - getRequiredDb(ctx), - eventReadRepository, - ctx.session.user.id, - ); - - const plansMap = new Map(plansWithEstimation.map((p: any) => [p.id, p])); - const itemsWithEstimation = events.map((event) => ({ - ...event, - activity_plan: event.activity_plan ? plansMap.get(event.activity_plan.id) : null, - })); - - const itemsWithStatus = await addEventLifecycleStatus(itemsWithEstimation, { - repository: eventReadRepository, - profileId: ctx.session.user.id, - }); - - return itemsWithStatus; - } - - const itemsWithStatus = await addEventLifecycleStatus(events, { - repository: eventReadRepository, - profileId: ctx.session.user.id, - }); - - return itemsWithStatus; - }), }); From 3b3066d131d97bf36fae8a154f83591412826fef Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Wed, 8 Jul 2026 09:03:16 -0400 Subject: [PATCH 11/12] Retire unused training plan helper endpoints --- .../training-plan-deeplink.jest.test.tsx | 5 - .../__tests__/plan-navigation.jest.test.tsx | 21 - .../training-plan/appliedScheduleUseCases.ts | 390 ---------- .../application/training-plan/crudUseCases.ts | 8 - .../training-plan/mutationUseCases.ts | 36 - .../training-plan/templateUseCases.ts | 26 - .../training-plans.apply-template.test.ts | 92 --- .../training-plans.system-plan-parity.test.ts | 67 -- .../routers/planning/training-plans/base.ts | 693 ------------------ 9 files changed, 1338 deletions(-) diff --git a/apps/mobile/app/(internal)/(standard)/__tests__/training-plan-deeplink.jest.test.tsx b/apps/mobile/app/(internal)/(standard)/__tests__/training-plan-deeplink.jest.test.tsx index 15c55c5a..cb485767 100644 --- a/apps/mobile/app/(internal)/(standard)/__tests__/training-plan-deeplink.jest.test.tsx +++ b/apps/mobile/app/(internal)/(standard)/__tests__/training-plan-deeplink.jest.test.tsx @@ -84,11 +84,6 @@ jest.mock("@/lib/api", () => ({ __esModule: true, api: { useUtils: () => ({ - client: { - trainingPlans: { - autoAddPeriodization: { mutate: jest.fn() }, - }, - }, trainingPlans: { invalidate: jest.fn(), }, diff --git a/apps/mobile/app/(internal)/(tabs)/__tests__/plan-navigation.jest.test.tsx b/apps/mobile/app/(internal)/(tabs)/__tests__/plan-navigation.jest.test.tsx index 98ac0d74..f88cb7b4 100644 --- a/apps/mobile/app/(internal)/(tabs)/__tests__/plan-navigation.jest.test.tsx +++ b/apps/mobile/app/(internal)/(tabs)/__tests__/plan-navigation.jest.test.tsx @@ -694,27 +694,6 @@ jest.mock("@/lib/api", () => ({ }; }, }, - simulateScheduleAdjustment: { - useQuery: (input: any, options: any) => ({ - data: options?.enabled - ? { - adjustment: { - date: input.adjustment_date, - tss_delta: input.tss_delta, - resulting_scheduled_load: 52 + input.tss_delta, - }, - comparison_date: input.comparison_date ?? "2026-04-10", - scheduled_readiness: 70, - simulated_readiness: 75.5, - readiness_delta: input.tss_delta === 105 ? 6.4 : 5.5, - scheduled_load: 52, - simulated_load: 52 + input.tss_delta, - confidence: "medium", - } - : null, - isFetching: false, - }), - }, list: { useInfiniteQuery: () => ({ data: { diff --git a/packages/api/src/application/training-plan/appliedScheduleUseCases.ts b/packages/api/src/application/training-plan/appliedScheduleUseCases.ts index 987fbb91..6063b25b 100644 --- a/packages/api/src/application/training-plan/appliedScheduleUseCases.ts +++ b/packages/api/src/application/training-plan/appliedScheduleUseCases.ts @@ -207,396 +207,6 @@ export async function updateActivePlanStatusUseCase(input: { }; } -export async function removeAppliedScheduleUseCase(input: { - db: DrizzleDbClient; - permissions: ContentPermissions; - profileId: string; - userTrainingPlanId: string; -}) { - const application = await getOwnedUserTrainingPlan(input); - - if (!application) { - throw new TRPCError({ code: "NOT_FOUND", message: "Scheduled plan not found" }); - } - - const deletedEvents = await input.db - .delete(schema.events) - .where( - and( - eq(schema.events.profile_id, input.profileId), - eq(schema.events.event_type, plannedEventType), - sql`exists ( - select 1 - from event_schedule_links - where event_schedule_links.event_id = events.id - and event_schedule_links.profile_id = ${input.profileId}::uuid - and event_schedule_links.user_training_plan_id = ${application.id}::uuid - )`, - gte(schema.events.starts_at, new Date(todayStartIsoUtc())), - ne(schema.events.status, "completed"), - ), - ) - .returning({ id: schema.events.id }); - - await Promise.all(deletedEvents.map((event) => input.permissions.revokeEventGrants(event.id))); - await enqueuePlannedWorkoutSyncForCalendarWrite({ - db: input.db, - eventIds: deletedEvents.map((event) => event.id), - operation: "unsync", - profileId: input.profileId, - }); - await input.db - .update(schema.userTrainingPlans) - .set({ status: "abandoned", updated_at: new Date() }) - .where(eq(schema.userTrainingPlans.id, application.id)); - - return { - success: true, - user_training_plan_id: application.id, - scheduled_sessions_removed: deletedEvents.length, - }; -} - -export async function shiftAppliedScheduleUseCase(input: { - days: number; - db: DrizzleDbClient; - profileId: string; - userTrainingPlanId: string; -}) { - const application = await getOwnedUserTrainingPlan(input); - - if (!application) { - throw new TRPCError({ code: "NOT_FOUND", message: "Scheduled plan not found" }); - } - - const futureEvents = await input.db - .select({ - id: schema.events.id, - activity_plan_id: schema.eventScheduleLinks.activity_plan_id, - starts_at: schema.events.starts_at, - ends_at: schema.events.ends_at, - }) - .from(schema.events) - .innerJoin(schema.eventScheduleLinks, eq(schema.eventScheduleLinks.event_id, schema.events.id)) - .where( - and( - eq(schema.events.profile_id, input.profileId), - eq(schema.events.event_type, plannedEventType), - eq(schema.eventScheduleLinks.user_training_plan_id, application.id), - gte(schema.events.starts_at, new Date(todayStartIsoUtc())), - ne(schema.events.status, "completed"), - ), - ) - .orderBy(asc(schema.events.starts_at)); - - if (futureEvents.length === 0) { - throw new TRPCError({ code: "NOT_FOUND", message: "No future scheduled sessions found" }); - } - - for (const event of futureEvents) { - const currentScheduledDate = formatDateOnlyUtc(event.starts_at); - const nextScheduledDate = addDaysDateOnlyUtc(currentScheduledDate, input.days); - await input.db - .update(schema.events) - .set({ - starts_at: new Date(toDayStartIso(nextScheduledDate)), - ends_at: new Date(toDayStartIso(addDaysDateOnlyUtc(nextScheduledDate, 1))), - status: "scheduled", - updated_at: new Date(), - }) - .where(eq(schema.events.id, event.id)); - } - - await enqueuePlannedWorkoutSyncForCalendarWrite({ - db: input.db, - eventIds: futureEvents - .filter((event) => Boolean(event.activity_plan_id)) - .map((event) => event.id), - operation: "publish", - profileId: input.profileId, - }); - - await input.db - .update(schema.userTrainingPlans) - .set({ - start_date: addDaysDateOnlyUtc(application.start_date, input.days), - target_date: application.target_date - ? addDaysDateOnlyUtc(application.target_date, input.days) - : null, - updated_at: new Date(), - }) - .where(eq(schema.userTrainingPlans.id, application.id)); - - return { - success: true, - days_shifted: input.days, - affected_count: futureEvents.length, - user_training_plan_id: application.id, - }; -} - -export async function regenerateAppliedScheduleUseCase(input: { - db: DrizzleDbClient; - permissions: ContentPermissions; - profileId: string; - repository: TrainingPlanRepository; - userTrainingPlanId: string; -}) { - const application = await getOwnedUserTrainingPlan(input); - - if (!application) { - throw new TRPCError({ code: "NOT_FOUND", message: "Scheduled plan not found" }); - } - - const trainingPlan = await input.repository.getAccessibleTrainingPlan({ - id: application.training_plan_id, - profileId: input.profileId, - }); - - if (!trainingPlan) { - throw new TRPCError({ code: "NOT_FOUND", message: "Training plan not found" }); - } - - const snapshotStructure = - application.snapshot_structure && typeof application.snapshot_structure === "object" - ? ({ ...(application.snapshot_structure as Record) } as Record< - string, - unknown - >) - : trainingPlan.structure && typeof trainingPlan.structure === "object" - ? ({ ...(trainingPlan.structure as Record) } as Record) - : {}; - const applicationMode = getApplicationModeFromSnapshot(snapshotStructure); - const snapshotApplication = - snapshotStructure._application && typeof snapshotStructure._application === "object" - ? (snapshotStructure._application as Record) - : null; - const targetDate = - typeof snapshotApplication?.target_date === "string" - ? snapshotApplication.target_date - : undefined; - const resolved = materializeAppliedTrainingPlan({ - applicationMode, - startDate: application.start_date, - targetDate, - structure: snapshotStructure, - todayDate: todayDateOnlyUtc(), - }); - - const removedEvents = await input.db - .delete(schema.events) - .where( - and( - eq(schema.events.profile_id, input.profileId), - eq(schema.events.event_type, plannedEventType), - sql`exists ( - select 1 - from event_schedule_links - where event_schedule_links.event_id = events.id - and event_schedule_links.profile_id = ${input.profileId}::uuid - and event_schedule_links.user_training_plan_id = ${application.id}::uuid - )`, - gte(schema.events.starts_at, new Date(todayStartIsoUtc())), - ne(schema.events.status, "completed"), - ), - ) - .returning({ id: schema.events.id }); - - await Promise.all(removedEvents.map((event) => input.permissions.revokeEventGrants(event.id))); - await enqueuePlannedWorkoutSyncForCalendarWrite({ - db: input.db, - eventIds: removedEvents.map((event) => event.id), - operation: "unsync", - profileId: input.profileId, - }); - - const candidatePlanIds = Array.from( - new Set( - resolved.materializedSessions - .map((session) => session.activity_plan_id) - .filter((id): id is string => Boolean(id)), - ), - ); - let allowedPlanIds = new Set(); - const allowedPlanNameById = new Map(); - const allowedPlanAccessById = new Map< - string, - { - ownerProfileId?: string | null; - isPublic?: boolean | null; - isSystem?: boolean | null; - routeId?: string | null; - } - >(); - - if (candidatePlanIds.length > 0) { - const accessiblePlans = await input.db.execute(sql<{ - id: string; - name: string; - ownerProfileId: string | null; - isPublic: boolean | null; - isSystem: boolean | null; - routeId: string | null; - }>` - select - id, - name, - profile_id as "ownerProfileId", - template_visibility = 'public' as "isPublic", - is_system_template as "isSystem", - route_id as "routeId" - from activity_plans - where id in (${sql.join( - candidatePlanIds.map((id) => sql`${id}::uuid`), - sql`, `, - )}) - and ( - profile_id = ${input.profileId}::uuid - or is_system_template = true - or template_visibility = 'public' - or exists ( - select 1 - from content_access_grants - where content_access_grants.content_type = 'activity_plan' - and content_access_grants.content_id = activity_plans.id - and content_access_grants.grantee_profile_id = ${input.profileId}::uuid - and content_access_grants.access_level = 'read' - and content_access_grants.revoked_at is null - and (content_access_grants.expires_at is null or content_access_grants.expires_at > now()) - ) - ) - `); - - const accessiblePlanRows = getSqlRows<{ - id: string; - name: string; - ownerProfileId?: string | null; - isPublic?: boolean | null; - isSystem?: boolean | null; - routeId?: string | null; - }>(accessiblePlans); - allowedPlanIds = new Set(accessiblePlanRows.map((row) => row.id)); - accessiblePlanRows.forEach((row) => { - allowedPlanNameById.set(row.id, row.name); - allowedPlanAccessById.set(row.id, { - ownerProfileId: row.ownerProfileId, - isPublic: row.isPublic, - isSystem: row.isSystem, - routeId: row.routeId, - }); - }); - } - - const eventRows = resolved.materializedSessions - .filter((session) => !session.activity_plan_id || allowedPlanIds.has(session.activity_plan_id)) - .map((session) => ({ - profile_id: input.profileId, - event_type: plannedEventType, - title: - session.event_title_override ?? - (session.activity_plan_id - ? allowedPlanNameById.get(session.activity_plan_id) - : undefined) ?? - session.title, - all_day: session.all_day, - timezone: "UTC", - starts_at: session.starts_at, - ends_at: session.ends_at, - status: "scheduled" as const, - activity_plan_id: session.activity_plan_id, - training_plan_id: trainingPlan.id, - user_training_plan_id: application.id, - payload: { - training_plan_generation: { - application_mode: resolved.applicationMode, - applied_start_date: resolved.appliedPlanStartDate, - source_day_offset: session.source_day_offset, - source_path: session.source_path, - target_date: resolved.targetDate, - user_training_plan_id: application.id, - }, - }, - })); - - if (eventRows.length === 0) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This scheduled plan no longer has any future sessions to regenerate.", - }); - } - - const schedule_batch_id = crypto.randomUUID(); - const insertedEvents = await input.db - .insert(schema.events) - .values(eventRows.map((row) => ({ ...row, schedule_batch_id })) as any) - .returning({ id: schema.events.id }); - - await Promise.all( - insertedEvents.map((event, index) => { - const eventRow = eventRows[index]; - if (!eventRow) { - return Promise.resolve(); - } - - const linkedPlanAccess = eventRow.activity_plan_id - ? allowedPlanAccessById.get(eventRow.activity_plan_id) - : null; - const shouldGrantLinkedPlan = linkedPlanAccess - ? needsContentGrantForRow(linkedPlanAccess, input.profileId) - : false; - const shouldGrantTrainingPlan = needsContentGrantForRow( - { - ownerProfileId: trainingPlan.profile_id, - isPublic: trainingPlan.template_visibility === "public", - isSystem: trainingPlan.is_system_template, - }, - input.profileId, - ); - - if (!shouldGrantLinkedPlan && !linkedPlanAccess?.routeId && !shouldGrantTrainingPlan) { - return Promise.resolve(); - } - - return input.permissions.grantEventContentAccess({ - actorProfileId: input.profileId, - granteeProfileId: input.profileId, - eventId: event.id, - activityPlanId: eventRow.activity_plan_id, - trainingPlanId: shouldGrantTrainingPlan ? trainingPlan.id : null, - }); - }), - ); - - await enqueuePlannedWorkoutSyncForCalendarWrite({ - db: input.db, - eventIds: insertedEvents - .filter((_, index) => Boolean(eventRows[index]?.activity_plan_id)) - .map((event) => event.id), - operation: "publish", - profileId: input.profileId, - }); - - await input.db - .update(schema.userTrainingPlans) - .set({ - start_date: resolved.appliedPlanStartDate, - target_date: resolved.targetDate, - snapshot_structure: resolved.snapshotStructure, - status: "active", - updated_at: new Date(), - }) - .where(eq(schema.userTrainingPlans.id, application.id)); - - return { - success: true, - schedule_batch_id, - scheduled_sessions_created: insertedEvents.length, - scheduled_sessions_replaced: removedEvents.length, - scheduled_sessions_skipped: resolved.skippedSessions, - user_training_plan_id: application.id, - }; -} - export async function getActivePlanUseCase(input: { profileId: string; repository: TrainingPlanRepository; diff --git a/packages/api/src/application/training-plan/crudUseCases.ts b/packages/api/src/application/training-plan/crudUseCases.ts index 1cb8b375..5f0e769b 100644 --- a/packages/api/src/application/training-plan/crudUseCases.ts +++ b/packages/api/src/application/training-plan/crudUseCases.ts @@ -189,11 +189,3 @@ export async function listTrainingPlansUseCase(input: { ...pageInfo, }; } - -export async function trainingPlanExistsUseCase(input: { - profileId: string; - repository: TrainingPlanRepository; -}) { - const count = await input.repository.countOwnedTrainingPlans(input.profileId); - return { exists: count > 0, count }; -} diff --git a/packages/api/src/application/training-plan/mutationUseCases.ts b/packages/api/src/application/training-plan/mutationUseCases.ts index ea00f2e3..ac0efa5e 100644 --- a/packages/api/src/application/training-plan/mutationUseCases.ts +++ b/packages/api/src/application/training-plan/mutationUseCases.ts @@ -393,39 +393,3 @@ export async function applyQuickAdjustmentUseCase(input: { return data; } - -export async function autoAddPeriodizationUseCase(input: { - id: string; - profileId: string; - repository: TrainingPlanRepository; -}) { - const existing = await input.repository.getOwnedTrainingPlan({ - id: input.id, - profileId: input.profileId, - }); - - if (!existing) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Training plan not found or you don't have permission to edit it", - }); - } - - const structure = existing.structure as { - plan_type?: unknown; - fitness_progression?: unknown; - } | null; - - if (structure?.plan_type === "periodized" && structure?.fitness_progression) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "This plan already has periodization configured", - }); - } - - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "Auto-periodization is not yet implemented. Please create a new periodized training plan or manually configure periodization in settings.", - }); -} diff --git a/packages/api/src/application/training-plan/templateUseCases.ts b/packages/api/src/application/training-plan/templateUseCases.ts index 50138f44..a866a6f1 100644 --- a/packages/api/src/application/training-plan/templateUseCases.ts +++ b/packages/api/src/application/training-plan/templateUseCases.ts @@ -133,32 +133,6 @@ export async function listTrainingPlanTemplatesUseCase(input: { }; } -export async function auditTrainingPlanTemplateHealthUseCase(input: { - repository: TrainingPlanRepository; -}) { - const templates = await input.repository.listPublicTemplateTrainingPlans(); - const items = templates.map((template) => { - const health = auditTrainingPlanTemplateStructureHealth({ structure: template.structure }); - - return { - id: template.id, - name: template.name, - ...health, - }; - }); - - return { - total: items.length, - healthy_count: items.filter((item) => item.isHealthy).length, - legacy_count: items.filter( - (item) => item.isPersistedCompatible && !item.isCurrentSchemaCompatible, - ).length, - invalid_count: items.filter((item) => !item.isPersistedCompatible).length, - metadata_gap_count: items.filter((item) => item.missingMetadata.length > 0).length, - items, - }; -} - export async function getTrainingPlanTemplateUseCase(input: { id: string; repository: TrainingPlanRepository; diff --git a/packages/api/src/routers/__tests__/training-plans.apply-template.test.ts b/packages/api/src/routers/__tests__/training-plans.apply-template.test.ts index bd335863..55bb661b 100644 --- a/packages/api/src/routers/__tests__/training-plans.apply-template.test.ts +++ b/packages/api/src/routers/__tests__/training-plans.apply-template.test.ts @@ -606,96 +606,4 @@ describe("trainingPlansRouter.applyTemplate", () => { vi.useRealTimers(); } }); - - it("shifts a grouped scheduled application and updates the application dates", async () => { - const { caller, callLog } = createCaller({ - user_training_plans: [ - { - data: [ - { - id: "44444444-4444-4444-8444-444444444444", - profile_id: "profile-123", - training_plan_id: "11111111-1111-4111-8111-111111111111", - status: "active", - start_date: "2026-03-10", - target_date: "2026-03-31", - snapshot_structure: {}, - created_at: "2026-03-01T00:00:00.000Z", - updated_at: "2026-03-01T00:00:00.000Z", - }, - ], - error: null, - }, - ], - events: { - data: [ - { - id: "event-1", - starts_at: "2026-03-18T00:00:00.000Z", - ends_at: "2026-03-19T00:00:00.000Z", - }, - { - id: "event-2", - starts_at: "2026-03-20T00:00:00.000Z", - ends_at: "2026-03-21T00:00:00.000Z", - }, - ], - error: null, - }, - }); - - const result = await caller.shiftAppliedSchedule({ - user_training_plan_id: "44444444-4444-4444-8444-444444444444", - days: 7, - }); - - const eventUpdates = callLog.filter( - (call) => call.table === "events" && call.operation === "update", - ); - const applicationUpdate = callLog.find( - (call) => call.table === "user_training_plans" && call.operation === "update", - ); - - expect(result.affected_count).toBe(2); - expect(eventUpdates).toHaveLength(2); - expect((eventUpdates[0]?.payload as Record)?.starts_at).toEqual( - new Date("2026-03-25T00:00:00.000Z"), - ); - expect((applicationUpdate?.payload as Record)?.start_date).toBe("2026-03-17"); - expect((applicationUpdate?.payload as Record)?.target_date).toBe("2026-04-07"); - }); - - it("removes a grouped scheduled application without touching completed history", async () => { - const { caller, callLog } = createCaller({ - user_training_plans: { - data: [ - { - id: "44444444-4444-4444-8444-444444444444", - profile_id: "profile-123", - training_plan_id: "11111111-1111-4111-8111-111111111111", - status: "active", - start_date: "2026-03-10", - target_date: null, - snapshot_structure: {}, - created_at: "2026-03-01T00:00:00.000Z", - updated_at: "2026-03-01T00:00:00.000Z", - }, - ], - error: null, - }, - events: { - data: [{ id: "event-1" }, { id: "event-2" }], - error: null, - }, - }); - - const result = await caller.removeAppliedSchedule({ - user_training_plan_id: "44444444-4444-4444-8444-444444444444", - }); - - expect(result.scheduled_sessions_removed).toBe(2); - expect( - callLog.some((call) => call.table === "user_training_plans" && call.operation === "update"), - ).toBe(true); - }); }); diff --git a/packages/api/src/routers/__tests__/training-plans.system-plan-parity.test.ts b/packages/api/src/routers/__tests__/training-plans.system-plan-parity.test.ts index 4d169da4..c66a9279 100644 --- a/packages/api/src/routers/__tests__/training-plans.system-plan-parity.test.ts +++ b/packages/api/src/routers/__tests__/training-plans.system-plan-parity.test.ts @@ -121,71 +121,4 @@ describe("system training-plan router parity", () => { expect(aggregateResult).toEqual(expectedTemplate); expect(crudResult).toEqual(expectedTemplate); }); - - it("auditTemplateHealth reports canonical seeded templates as healthy", async () => { - const aggregateCaller = createCaller(trainingPlansRouter, rows); - const crudCaller = createCaller(trainingPlansCrudRouter, rows); - - const [aggregateResult, crudResult] = await Promise.all([ - aggregateCaller.auditTemplateHealth(), - crudCaller.auditTemplateHealth(), - ]); - - expect(aggregateResult).toEqual(crudResult); - expect(aggregateResult.total).toBe(ALL_SAMPLE_PLANS.length); - expect(aggregateResult.healthy_count).toBe(ALL_SAMPLE_PLANS.length); - expect(aggregateResult.legacy_count).toBe(0); - expect(aggregateResult.invalid_count).toBe(0); - expect(aggregateResult.metadata_gap_count).toBe(0); - expect(aggregateResult.items.every((item) => item.isPersistedCompatible)).toBe(true); - expect(aggregateResult.items.every((item) => item.isCurrentSchemaCompatible)).toBe(true); - expect(aggregateResult.items.every((item) => item.missingMetadata.length === 0)).toBe(true); - expect(aggregateResult.items.every((item) => item.isHealthy)).toBe(true); - }); - - it("auditTemplateHealth flags legacy seeded templates before they break actions", async () => { - const legacyRows = [ - { - ...toTrainingPlanRow(ALL_SAMPLE_PLANS[0]!), - structure: { - version: 1, - start_date: "2026-01-05", - target_weekly_tss_min: 280, - target_weekly_tss_max: 420, - target_activities_per_week: 4, - max_consecutive_days: 3, - min_rest_days_per_week: 2, - sessions: [ - { - offset_days: 1, - title: "Easy Run", - session_type: "planned", - activity_plan_id: "1b4c5d6e-7f8a-4b0c-9d2e-3f4a5b6c7d8e", - }, - ], - }, - }, - ]; - - const caller = createCaller(trainingPlansRouter, legacyRows); - const result = await caller.auditTemplateHealth(); - - expect(result.total).toBe(1); - expect(result.healthy_count).toBe(0); - expect(result.legacy_count).toBe(1); - expect(result.invalid_count).toBe(0); - expect(result.metadata_gap_count).toBe(1); - expect(result.items[0]).toMatchObject({ - isHealthy: false, - isPersistedCompatible: true, - isCurrentSchemaCompatible: false, - missingMetadata: ["sport", "experienceLevel", "durationWeeks"], - issueCodes: [ - "legacy_structure", - "missing_sport_metadata", - "missing_experience_level_metadata", - "missing_duration_weeks_metadata", - ], - }); - }); }); diff --git a/packages/api/src/routers/planning/training-plans/base.ts b/packages/api/src/routers/planning/training-plans/base.ts index 0e33a3a6..a6402146 100644 --- a/packages/api/src/routers/planning/training-plans/base.ts +++ b/packages/api/src/routers/planning/training-plans/base.ts @@ -83,8 +83,6 @@ import { z } from "zod"; import { applyQuickAdjustmentUseCase, applyTrainingPlanTemplateUseCase, - auditTrainingPlanTemplateHealthUseCase, - autoAddPeriodizationUseCase, createFromCreationConfigUseCase, createTrainingPlanUseCase, deleteTrainingPlanUseCase, @@ -97,10 +95,6 @@ import { listTrainingPlansUseCase, listTrainingPlanTemplatesUseCase, previewCreationConfigUseCase, - regenerateAppliedScheduleUseCase, - removeAppliedScheduleUseCase, - shiftAppliedScheduleUseCase, - trainingPlanExistsUseCase, updateActivePlanStatusUseCase, updateFromCreationConfigUseCase, updateTrainingPlanUseCase, @@ -4460,17 +4454,6 @@ const trainingPlansProcedures = { }); }), - // ------------------------------ - // Check if user has a training plan - // ------------------------------ - exists: protectedProcedure.query(async ({ ctx }) => { - const db = getRequiredDb(ctx); - return trainingPlanExistsUseCase({ - profileId: ctx.session.user.id, - repository: createTrainingPlanRepository(db), - }); - }), - // ------------------------------ // Create new training plan // Can create multiple plans; if is_active, deactivates others @@ -4486,108 +4469,6 @@ const trainingPlansProcedures = { }); }), - // ------------------------------ - // Preview feasibility/safety from minimal goal payload - // ------------------------------ - getFeasibilityPreview: protectedProcedure - .input(minimalTrainingPlanCreateSchema) - .query(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - const store = createActivityAnalysisStore(db); - const estimatedCurrentCtl = await estimateCurrentCtl({ - db, - store, - profileId: ctx.session.user.id, - }); - const expandedPlan = buildExpandedPlanFromMinimalGoal(input, { - startingCtl: estimatedCurrentCtl, - }); - const normalizedGoals = expandedPlan.goals; - - const referenceDate = formatDateOnlyUtc(new Date()); - - const assessmentGoals = normalizedGoals.map((goal) => ({ - id: goal.id, - name: goal.name, - target_date: goal.target_date, - priority: goal.priority, - })); - - const nextGoal = [...assessmentGoals].sort((a: any, b: any) => - a.target_date.localeCompare(b.target_date), - )[0]; - - const previewPlanWithId = { - ...expandedPlan, - id: deterministicUuidFromSeed( - `${ctx.session.user.id}|${assessmentGoals.map((goal) => goal.id).join("|")}|preview-plan`, - ), - }; - - const parsedPreviewPlan = legacyStructuredTrainingPlanSchema.safeParse(previewPlanWithId); - const planWarnings = - parsedPreviewPlan.success && parsedPreviewPlan.data.plan_type === "periodized" - ? validatePlanFeasibility(parsedPreviewPlan.data).warnings - : []; - - const blockRampWarnings = collectBlockRampWarnings(expandedPlan.blocks); - const assessments = buildPlanAssessments({ - goals: assessmentGoals, - referenceDate, - currentCtl: estimatedCurrentCtl, - targetCtlAtPeak: expandedPlan.fitness_progression.target_ctl_at_peak, - planWarnings, - blockRampWarnings, - }); - - const planDurationDays = Math.max( - 0, - diffDateOnlyUtcDays(expandedPlan.start_date, expandedPlan.end_date) + 1, - ); - const targetWeeklyTssAvg = - expandedPlan.blocks.length > 0 - ? expandedPlan.blocks.reduce((sum, block) => { - const range = block.target_weekly_tss_range; - return sum + (range.min + range.max) / 2; - }, 0) / expandedPlan.blocks.length - : 0; - - return { - plan_assessment: { - feasibility: assessments.planFeasibility, - safety: assessments.planSafety, - }, - goal_assessments: assessments.goalFeasibility.map((goalFeasibility) => { - const goalSafety = assessments.goalSafety.find( - (goal) => goal.goal_id === goalFeasibility.goal_id, - ); - - return { - goal_id: goalFeasibility.goal_id, - goal_name: goalFeasibility.goal_name, - feasibility: { - state: goalFeasibility.state, - reasons: goalFeasibility.reasons, - }, - safety: { - state: goalSafety?.state ?? "safe", - reasons: goalSafety?.reasons ?? [], - }, - }; - }), - key_metrics: { - reference_date: referenceDate, - days_until_goal: nextGoal ? diffDateOnlyUtcDays(referenceDate, nextGoal.target_date) : 0, - plan_duration_days: planDurationDays, - block_count: expandedPlan.blocks.length, - goal_count: assessmentGoals.length, - estimated_current_ctl: estimatedCurrentCtl, - target_weekly_tss_avg: Math.round(targetWeeklyTssAvg), - }, - normalized_goals: normalizedGoals, - }; - }), - // ------------------------------ // Derive profile-aware creation context + suggestions // ------------------------------ @@ -4727,48 +4608,6 @@ const trainingPlansProcedures = { })) as any; }), - // ------------------------------ - // Create training plan from minimal goal payload - // ------------------------------ - createFromMinimalGoal: protectedProcedure - .input(minimalTrainingPlanCreateSchema) - .mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - const store = createActivityAnalysisStore(db); - const repository = createTrainingPlanRepository(db); - const estimatedCurrentCtl = await estimateCurrentCtl({ - db, - store, - profileId: ctx.session.user.id, - }); - const expandedPlan = buildExpandedPlanFromMinimalGoal(input, { - startingCtl: estimatedCurrentCtl, - }); - - const planId = crypto.randomUUID(); - const structureWithId = { - ...expandedPlan, - id: planId, - }; - - try { - legacyStructuredTrainingPlanSchema.parse(structureWithId); - } catch (validationError) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Generated training plan structure is invalid", - cause: validationError, - }); - } - - return repository.createTrainingPlan({ - name: expandedPlan.name, - description: expandedPlan.description ?? null, - structure: structureWithId, - profileId: ctx.session.user.id, - }); - }), - // ------------------------------ // Canonical insight timeline (MVP deterministic baseline) // ------------------------------ @@ -4784,30 +4623,6 @@ const trainingPlansProcedures = { }); }), - simulateScheduleAdjustment: protectedProcedure - .input(scheduleAdjustmentSimulationInputSchema) - .query(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - const result = await getPlanTabProjectionService({ - db, - store: createActivityAnalysisStore(db), - profileId: ctx.session.user.id, - input: { - training_plan_id: input.training_plan_id, - start_date: input.start_date, - end_date: input.end_date, - timezone: input.timezone, - schedule_adjustment: { - date: input.adjustment_date, - tss_delta: input.tss_delta, - comparison_date: input.comparison_date, - }, - }, - }); - - return result.schedule_simulation; - }), - // ------------------------------ // Update training plan // ------------------------------ @@ -5618,444 +5433,6 @@ const trainingPlansProcedures = { return weekSummaries; }), - // ------------------------------ - // Get intensity distribution (actual from completed activities) - // Uses 7-zone system: Recovery, Endurance, Tempo, Threshold, VO2max, Anaerobic, Neuromuscular - // ------------------------------ - getIntensityDistribution: protectedProcedure - .input( - z.object({ - training_plan_id: z.string().uuid().optional(), - start_date: z.string(), - end_date: z.string(), - }), - ) - .query(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - // Get completed activities in date range with intensity_factor - const activities = await db - .select(activitySummaryColumns) - .from(schema.activities) - .innerJoin( - schema.activitySummaries, - eq(schema.activitySummaries.activity_id, schema.activities.id), - ) - .where( - and( - eq(schema.activities.profile_id, ctx.session.user.id), - gte(schema.activities.started_at, new Date(input.start_date)), - lte(schema.activities.started_at, new Date(input.end_date)), - ), - ) - .orderBy(desc(schema.activities.started_at)); - - const derivedMap = await buildActivityDerivedSummaryMap({ - store: createActivityAnalysisStore(db), - profileId: ctx.session.user.id, - activities, - }); - - const totalActivities = activities.length; - - // Initialize 7-zone distribution (TSS-weighted) - type IntensityZone = - | "recovery" - | "endurance" - | "tempo" - | "threshold" - | "vo2max" - | "anaerobic" - | "neuromuscular"; - const zoneDistribution: Record = { - recovery: 0, - endurance: 0, - tempo: 0, - threshold: 0, - vo2max: 0, - anaerobic: 0, - neuromuscular: 0, - }; - - let totalTSS = 0; - - // Calculate actual distribution from IF values - if (activities.length > 0) { - for (const activity of activities) { - const intensityFactorValue = derivedMap.get(activity.id)?.intensity_factor || 0; - const tss = derivedMap.get(activity.id)?.tss || 0; - - if (!intensityFactorValue || !tss) { - continue; - } - const intensityFactor = intensityFactorValue; - - // Get the zone for this IF value - const zone = getTrainingIntensityZone(intensityFactor) as IntensityZone; - - // Add TSS to the appropriate zone - zoneDistribution[zone] = (zoneDistribution[zone] || 0) + tss; - totalTSS += tss; - } - - // Convert TSS values to percentages - if (totalTSS > 0) { - for (const zone in zoneDistribution) { - const zoneKey = zone as IntensityZone; - zoneDistribution[zoneKey] = (zoneDistribution[zoneKey] / totalTSS) * 100; - } - } - } - - // Generate recommendations based on training science - const recommendations: string[] = []; - const recoveryPct = zoneDistribution.recovery || 0; - const endurancePct = zoneDistribution.endurance || 0; - const hardPct = - (zoneDistribution.threshold || 0) + - (zoneDistribution.vo2max || 0) + - (zoneDistribution.anaerobic || 0) + - (zoneDistribution.neuromuscular || 0); - - // Polarized training: ~80% easy (recovery + endurance), ~20% hard - const easyPct = recoveryPct + endurancePct; - - if (totalActivities >= 5) { - // Only provide recommendations if we have enough data - if (easyPct < 70) { - recommendations.push( - "Consider adding more easy/recovery activities. Aim for ~80% of training at low intensity.", - ); - } else if (easyPct > 90) { - recommendations.push( - "Consider adding some high-intensity sessions to stimulate adaptation.", - ); - } - - if (hardPct > 30) { - recommendations.push( - "High volume of hard training detected. Ensure adequate recovery to prevent overtraining.", - ); - } - - if ((zoneDistribution.tempo || 0) > 20) { - recommendations.push( - "High tempo training detected. This 'gray zone' may limit polarization benefits.", - ); - } - } else if (totalActivities > 0) { - recommendations.push( - "Complete more activities to see meaningful intensity distribution analysis.", - ); - } else { - recommendations.push( - "No completed activities in this date range. Start training to see your intensity distribution!", - ); - } - - return { - distribution: { - recovery: Math.round((zoneDistribution.recovery || 0) * 10) / 10, - endurance: Math.round((zoneDistribution.endurance || 0) * 10) / 10, - tempo: Math.round((zoneDistribution.tempo || 0) * 10) / 10, - threshold: Math.round((zoneDistribution.threshold || 0) * 10) / 10, - vo2max: Math.round((zoneDistribution.vo2max || 0) * 10) / 10, - anaerobic: Math.round((zoneDistribution.anaerobic || 0) * 10) / 10, - neuromuscular: Math.round((zoneDistribution.neuromuscular || 0) * 10) / 10, - }, - totalActivities, - totalTSS: Math.round(totalTSS), - activitiesWithIntensity: - activities?.filter((a: any) => { - const intensityFactor = derivedMap.get(a.id)?.intensity_factor; - return intensityFactor !== null && intensityFactor !== undefined; - }).length || 0, - recommendations, - }; - }), - - // Get intensity trends over time - // ------------------------------ - getIntensityTrends: protectedProcedure - .input( - z.object({ - weeks_back: z.number().int().min(1).max(52).default(12), - }), - ) - .query(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - const endDate = new Date(); - const startDate = new Date(); - startDate.setDate(startDate.getDate() - input.weeks_back * 7); - - // Get activities with IF values - const activities = await db - .select(activitySummaryColumns) - .from(schema.activities) - .innerJoin( - schema.activitySummaries, - eq(schema.activitySummaries.activity_id, schema.activities.id), - ) - .where( - and( - eq(schema.activities.profile_id, ctx.session.user.id), - gte(schema.activities.started_at, startDate), - lte(schema.activities.started_at, endDate), - ), - ) - .orderBy(asc(schema.activities.started_at)); - - const derivedMap = await buildActivityDerivedSummaryMap({ - store: createActivityAnalysisStore(db), - profileId: ctx.session.user.id, - activities, - }); - - // Group by week - type IntensityZone = - | "recovery" - | "endurance" - | "tempo" - | "threshold" - | "vo2max" - | "anaerobic" - | "neuromuscular"; - const weeklyData: Record< - string, - { - weekStart: string; - totalTSS: number; - avgIF: number; - activities: number; - zones: Record; - } - > = {}; - - if (activities.length > 0) { - for (const activity of activities) { - const date = new Date(activity.started_at); - // Get Monday of the week - const weekStart = new Date(date); - weekStart.setDate(date.getDate() - date.getDay() + 1); - const weekKey = weekStart.toISOString().split("T")[0] || ""; - - if (!weeklyData[weekKey]) { - weeklyData[weekKey] = { - weekStart: weekKey, - totalTSS: 0, - avgIF: 0, - activities: 0, - zones: { - recovery: 0, - endurance: 0, - tempo: 0, - threshold: 0, - vo2max: 0, - anaerobic: 0, - neuromuscular: 0, - }, - }; - } - - const intensityFactorValue = derivedMap.get(activity.id)?.intensity_factor || 0; - - if (!intensityFactorValue) continue; - - const intensityFactor = intensityFactorValue; // Assuming float 0.85 - const tss = derivedMap.get(activity.id)?.tss || 0; - const zone = getTrainingIntensityZone(intensityFactor) as IntensityZone; - - const week = weeklyData[weekKey]; - if (week && weekKey) { - week.totalTSS += tss; - week.avgIF += intensityFactor; - week.activities += 1; - week.zones[zone] = (week.zones[zone] || 0) + tss; - } - } - - // Calculate averages and percentages - for (const week of Object.values(weeklyData)) { - week.avgIF = week.avgIF / week.activities; - - // Convert zone TSS to percentages - if (week.totalTSS > 0) { - for (const zone in week.zones) { - const zoneKey = zone as IntensityZone; - week.zones[zoneKey] = (week.zones[zoneKey] / week.totalTSS) * 100; - } - } - } - } - - return { - weeks: Object.values(weeklyData).sort( - (a, b) => new Date(a.weekStart).getTime() - new Date(b.weekStart).getTime(), - ), - totalActivities: activities.length, - }; - }), - - // Check hard activity spacing (retrospective analysis) - // ------------------------------ - checkHardActivitySpacing: protectedProcedure - .input( - z.object({ - start_date: z.string(), - end_date: z.string(), - min_hours: z.number().int().min(24).max(168).default(48), - }), - ) - .query(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - // Get activities with IF >= 0.85 (threshold and above) - const allActivities = await db - .select({ - ...activitySummaryColumns, - name: schema.activities.name, - }) - .from(schema.activities) - .innerJoin( - schema.activitySummaries, - eq(schema.activitySummaries.activity_id, schema.activities.id), - ) - .where( - and( - eq(schema.activities.profile_id, ctx.session.user.id), - gte(schema.activities.started_at, new Date(input.start_date)), - lte(schema.activities.started_at, new Date(input.end_date)), - ), - ) - .orderBy(asc(schema.activities.started_at)); - - const derivedMap = await buildActivityDerivedSummaryMap({ - store: createActivityAnalysisStore(db), - profileId: ctx.session.user.id, - activities: allActivities, - }); - - // Filter activities with IF >= 0.85 - const activities = allActivities.filter( - (a: any) => (derivedMap.get(a.id)?.intensity_factor || 0) >= 0.85, - ); - - const violations: Array<{ - activity1: { - id: string; - name: string; - started_at: string; - intensity_factor: number; - }; - activity2: { - id: string; - name: string; - started_at: string; - intensity_factor: number; - }; - hoursBetween: number; - }> = []; - - if (activities && activities.length > 1) { - for (let i = 1; i < activities.length; i++) { - const prev = activities[i - 1]; - const curr = activities[i]; - - if (!prev || !curr) continue; - - const hoursBetween = - (new Date(curr.started_at).getTime() - new Date(prev.started_at).getTime()) / - (1000 * 60 * 60); - - if (hoursBetween < input.min_hours) { - violations.push({ - activity1: { - id: prev.id, - name: prev.name || "Unnamed activity", - started_at: prev.started_at.toISOString(), - intensity_factor: derivedMap.get(prev.id)?.intensity_factor ?? 0, - }, - activity2: { - id: curr.id, - name: curr.name || "Unnamed activity", - started_at: curr.started_at.toISOString(), - intensity_factor: derivedMap.get(curr.id)?.intensity_factor ?? 0, - }, - hoursBetween: Math.round(hoursBetween * 10) / 10, - }); - } - } - } - - return { - violations, - hardActivityCount: activities?.length || 0, - hasViolations: violations.length > 0, - }; - }), - - // ------------------------------ - // Get weekly totals (distance, time, count) for current week - // ------------------------------ - getWeeklyTotals: protectedProcedure - .input( - z - .object({ - weekStartDate: z.string().optional(), - }) - .optional(), - ) - .query(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - // Calculate week boundaries (Sunday to Saturday) - const today = new Date(); - const weekStart = input?.weekStartDate ? new Date(input.weekStartDate) : new Date(today); - - // Set to start of week (Sunday) - if (!input?.weekStartDate) { - weekStart.setDate(today.getDate() - today.getDay()); - } - weekStart.setHours(0, 0, 0, 0); - - const weekEnd = new Date(weekStart); - weekEnd.setDate(weekStart.getDate() + 7); - - // Get completed activities for this week - const activities = await db - .select({ - distance_meters: schema.activitySummaries.distance_meters, - duration_seconds: schema.activitySummaries.duration_seconds, - }) - .from(schema.activities) - .innerJoin( - schema.activitySummaries, - eq(schema.activitySummaries.activity_id, schema.activities.id), - ) - .where( - and( - eq(schema.activities.profile_id, ctx.session.user.id), - gte(schema.activities.started_at, weekStart), - lt(schema.activities.started_at, weekEnd), - ), - ); - - // Sum totals - let totalDistance = 0; - let totalTime = 0; - const count = activities.length; - - if (activities.length > 0) { - for (const activity of activities) { - totalDistance += activity.distance_meters || 0; - totalTime += activity.duration_seconds || 0; - } - } - - return { - distance: Math.round(totalDistance * 100) / 100, // meters - time: Math.round(totalTime), // seconds - count, - }; - }), - // ------------------------------ // List training plan templates // ------------------------------ @@ -6095,13 +5472,6 @@ const trainingPlansProcedures = { }); }), - auditTemplateHealth: protectedProcedure.query(async ({ ctx }) => { - const db = getRequiredDb(ctx); - return auditTrainingPlanTemplateHealthUseCase({ - repository: createTrainingPlanRepository(db), - }); - }), - // ------------------------------ // Get single training plan template // ------------------------------ @@ -6152,106 +5522,43 @@ const trainingPlansProcedures = { }); }), - removeAppliedSchedule: protectedProcedure - .input(applicationScopedScheduleInputSchema) - .mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - return removeAppliedScheduleUseCase({ - db, - permissions: createContentAccessPermissions(db), - profileId: ctx.session.user.id, - userTrainingPlanId: input.user_training_plan_id, - }); - }), - - shiftAppliedSchedule: protectedProcedure - .input(shiftScheduledPlanInputSchema) - .mutation(async ({ ctx, input }) => { - return shiftAppliedScheduleUseCase({ - days: input.days, - db: getRequiredDb(ctx), - profileId: ctx.session.user.id, - userTrainingPlanId: input.user_training_plan_id, - }); - }), - - regenerateAppliedSchedule: protectedProcedure - .input(applicationScopedScheduleInputSchema) - .mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - return regenerateAppliedScheduleUseCase({ - db, - permissions: createContentAccessPermissions(db), - profileId: ctx.session.user.id, - repository: createTrainingPlanRepository(db), - userTrainingPlanId: input.user_training_plan_id, - }); - }), - getActivePlan: protectedProcedure.query(async ({ ctx }) => { return getActivePlanUseCase({ profileId: ctx.session.user.id, repository: createTrainingPlanRepository(getRequiredDb(ctx)), }); }), - - // ------------------------------ - // Auto-add periodization to existing plan - // ------------------------------ - autoAddPeriodization: protectedProcedure - .input(z.object({ id: z.string().uuid() })) - .mutation(async ({ ctx, input }) => { - const db = getRequiredDb(ctx); - return autoAddPeriodizationUseCase({ - id: input.id, - profileId: ctx.session.user.id, - repository: createTrainingPlanRepository(db), - }); - }), }; export const trainingPlansCreationProcedures = { - getFeasibilityPreview: trainingPlansProcedures.getFeasibilityPreview, getCreationSuggestions: trainingPlansProcedures.getCreationSuggestions, previewCreationConfig: trainingPlansProcedures.previewCreationConfig, createFromCreationConfig: trainingPlansProcedures.createFromCreationConfig, updateFromCreationConfig: trainingPlansProcedures.updateFromCreationConfig, - createFromMinimalGoal: trainingPlansProcedures.createFromMinimalGoal, }; export const trainingPlansCrudProcedures = { get: trainingPlansProcedures.get, list: trainingPlansProcedures.list, - exists: trainingPlansProcedures.exists, create: trainingPlansProcedures.create, update: trainingPlansProcedures.update, updateActivePlanStatus: trainingPlansProcedures.updateActivePlanStatus, getActivePlan: trainingPlansProcedures.getActivePlan, - removeAppliedSchedule: trainingPlansProcedures.removeAppliedSchedule, - shiftAppliedSchedule: trainingPlansProcedures.shiftAppliedSchedule, - regenerateAppliedSchedule: trainingPlansProcedures.regenerateAppliedSchedule, delete: trainingPlansProcedures.delete, duplicate: trainingPlansProcedures.duplicate, getById: trainingPlansProcedures.getById, applyQuickAdjustment: trainingPlansProcedures.applyQuickAdjustment, listTemplates: trainingPlansProcedures.listTemplates, - auditTemplateHealth: trainingPlansProcedures.auditTemplateHealth, getTemplate: trainingPlansProcedures.getTemplate, applyTemplate: trainingPlansProcedures.applyTemplate, - autoAddPeriodization: trainingPlansProcedures.autoAddPeriodization, }; export const trainingPlansAnalyticsProcedures = { getInsightTimeline: trainingPlansProcedures.getInsightTimeline, - simulateScheduleAdjustment: trainingPlansProcedures.simulateScheduleAdjustment, getCurrentStatus: trainingPlansProcedures.getCurrentStatus, getIdealCurve: trainingPlansProcedures.getIdealCurve, getActualCurve: trainingPlansProcedures.getActualCurve, getWeeklySummary: trainingPlansProcedures.getWeeklySummary, - getIntensityDistribution: trainingPlansProcedures.getIntensityDistribution, - getIntensityTrends: trainingPlansProcedures.getIntensityTrends, - checkHardActivitySpacing: trainingPlansProcedures.checkHardActivitySpacing, - getWeeklyTotals: trainingPlansProcedures.getWeeklyTotals, }; export const trainingPlansRouter = createTRPCRouter({ From cc261a6c14805e837bc4094bd3c3408a439c5e58 Mon Sep 17 00:00:00 2001 From: Dean Cochran Date: Wed, 8 Jul 2026 09:09:19 -0400 Subject: [PATCH 12/12] Retire unused event provider sync status endpoint --- .../calendar-mutation-sync.ts | 137 ------------------ packages/api/src/routers/events.ts | 31 +--- 2 files changed, 1 insertion(+), 167 deletions(-) diff --git a/packages/api/src/lib/provider-sync/planned-workouts/calendar-mutation-sync.ts b/packages/api/src/lib/provider-sync/planned-workouts/calendar-mutation-sync.ts index b26cf2cd..bfd8d6bd 100644 --- a/packages/api/src/lib/provider-sync/planned-workouts/calendar-mutation-sync.ts +++ b/packages/api/src/lib/provider-sync/planned-workouts/calendar-mutation-sync.ts @@ -1,4 +1,3 @@ -import { getProvidersWithCapability } from "@repo/core"; import type { DrizzleDbClient } from "@repo/db"; import { createIntegrationsRepositories, @@ -18,15 +17,6 @@ export type CalendarMutationPlannedWorkoutSyncInput = { profileId: string; }; -export type EventPlannedWorkoutSyncStatus = - | "not_connected" - | "not_synced" - | "queued" - | "scheduled" - | "synced" - | "failed" - | "needs_reconnect"; - export function createPlannedWorkoutSyncServiceForDb(db: DrizzleDbClient) { const providerSyncRepository = createProviderSyncRepository({ db }); const wahooRepository = createWahooRepository({ db }); @@ -38,8 +28,6 @@ export function createPlannedWorkoutSyncServiceForDb(db: DrizzleDbClient) { }); } -const supportedPlannedWorkoutProviders = ["wahoo"] as const; - export async function enqueuePlannedWorkoutSyncAfterCalendarMutation( input: CalendarMutationPlannedWorkoutSyncInput, ): Promise { @@ -70,128 +58,3 @@ export async function enqueuePlannedWorkoutSyncAfterCalendarMutation( return result; } - -export async function getEventPlannedWorkoutProviderStatuses(input: { - db: DrizzleDbClient; - eventId: string; - profileId: string; -}) { - const repositories = createIntegrationsRepositories(input.db); - const providerSyncRepository = createProviderSyncRepository({ db: input.db }); - const wahooRepository = createWahooRepository({ db: input.db }); - const integrations = await repositories.integrations.listByProfileId(input.profileId); - const connectedByProvider = new Map( - integrations.map((integration) => [integration.provider, integration]), - ); - const providers = getProvidersWithCapability( - supportedPlannedWorkoutProviders, - "planned_activity_push", - ); - const jobs = await providerSyncRepository.listJobs({ - limit: 100, - profileId: input.profileId, - statuses: ["queued", "running", "failed", "dead_lettered"], - }); - const links = await wahooRepository.listEventResourceLinks({ - eventId: input.eventId, - profileId: input.profileId, - }); - const credentialEntries = await Promise.all( - providers.map( - async (provider) => - [ - provider, - await repositories.integrations.findCredentialsByProfileIdAndProvider({ - profileId: input.profileId, - provider, - }), - ] as const, - ), - ); - const credentialsByProvider = new Map(credentialEntries); - const now = Date.now(); - - return providers.map((provider) => { - const integration = connectedByProvider.get(provider); - if (!integration) { - return { - provider, - status: "not_connected" satisfies EventPlannedWorkoutSyncStatus, - jobId: null, - runAt: null, - lastError: null, - externalId: null, - syncedAt: null, - }; - } - - const credentials = credentialsByProvider.get(provider); - if ( - credentials?.expires_at && - credentials.expires_at.getTime() <= now && - !credentials.refresh_token - ) { - return { - provider, - status: "needs_reconnect" satisfies EventPlannedWorkoutSyncStatus, - jobId: null, - runAt: null, - lastError: "Provider access expired", - externalId: null, - syncedAt: null, - }; - } - - const latestJob = jobs.find( - (job) => job.provider === provider && job.internalResourceId === input.eventId, - ); - if (latestJob?.status === "failed" || latestJob?.status === "dead_lettered") { - return { - provider, - status: "failed" satisfies EventPlannedWorkoutSyncStatus, - jobId: latestJob.id, - runAt: latestJob.runAt, - lastError: latestJob.lastError, - externalId: null, - syncedAt: null, - }; - } - - if (latestJob?.status === "queued" || latestJob?.status === "running") { - return { - provider, - status: (Date.parse(latestJob.runAt) > now - ? "scheduled" - : "queued") satisfies EventPlannedWorkoutSyncStatus, - jobId: latestJob.id, - runAt: latestJob.runAt, - lastError: null, - externalId: null, - syncedAt: null, - }; - } - - const link = links.find((candidate) => candidate.provider === provider); - if (link) { - return { - provider, - status: "synced" satisfies EventPlannedWorkoutSyncStatus, - jobId: null, - runAt: null, - lastError: null, - externalId: link.externalId, - syncedAt: link.syncedAt, - }; - } - - return { - provider, - status: "not_synced" satisfies EventPlannedWorkoutSyncStatus, - jobId: null, - runAt: null, - lastError: null, - externalId: null, - syncedAt: null, - }; - }); -} diff --git a/packages/api/src/routers/events.ts b/packages/api/src/routers/events.ts index 9ab58ad4..cd3675e2 100644 --- a/packages/api/src/routers/events.ts +++ b/packages/api/src/routers/events.ts @@ -24,10 +24,7 @@ import { createEventReadRepository, createEventWriteRepository, } from "../infrastructure/repositories"; -import { - getEventPlannedWorkoutProviderStatuses, - type PlannedWorkoutQueueResult, -} from "../lib/provider-sync/planned-workouts"; +import type { PlannedWorkoutQueueResult } from "../lib/provider-sync/planned-workouts"; import { createContentAccessPermissions } from "../permissions/content-access"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { @@ -937,32 +934,6 @@ export const eventsRouter = createTRPCRouter({ return event; }), - getProviderSyncStatus: protectedProcedure - .input(z.object({ eventId: z.string().uuid() })) - .query(async ({ ctx, input }) => { - const eventReadRepository = getEventReadRepository(ctx); - const event = await eventReadRepository.getOwnedEventById({ - eventId: input.eventId, - profileId: ctx.session.user.id, - }); - - if (!event) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Event not found", - }); - } - - return { - eventId: input.eventId, - plannedWorkoutSync: await getEventPlannedWorkoutProviderStatuses({ - db: getRequiredDb(ctx), - eventId: input.eventId, - profileId: ctx.session.user.id, - }), - }; - }), - getToday: protectedProcedure.query(async ({ ctx }) => { const eventReadRepository = getEventReadRepository(ctx); const today = toDateKey(new Date().toISOString());