diff --git a/src/ui/admin/readModels/searchLanguagesReadModel.test.ts b/src/ui/admin/readModels/searchLanguagesReadModel.test.ts index 8f8ea327..4ef37a40 100644 --- a/src/ui/admin/readModels/searchLanguagesReadModel.test.ts +++ b/src/ui/admin/readModels/searchLanguagesReadModel.test.ts @@ -1,5 +1,10 @@ import { initializeDatabase } from "@/tests/vitest/dbUtils"; +import { getDb } from "@/db"; +import { TRANSLATION_JOB_TYPES } from "@/modules/translation/jobs/jobType"; +import { JobStatus } from "@/shared/jobs/model"; +import { ulid } from "@/shared/ulid"; import { expect, test } from "vitest"; + import { searchLanguagesReadModel } from "@/ui/admin/readModels/searchLanguagesReadModel"; import { languageFactory } from "@/modules/languages/test-utils/languageFactory"; @@ -42,6 +47,10 @@ test("fetches the first page", async () => { localName: lang1.local_name, otProgress: 0, ntProgress: 0, + aiGlosses: { + status: "unavailable", + lastSyncedAt: null, + }, }, { code: lang2.code, @@ -49,6 +58,10 @@ test("fetches the first page", async () => { localName: lang2.local_name, otProgress: 0, ntProgress: 0, + aiGlosses: { + status: "unavailable", + lastSyncedAt: null, + }, }, ], }); @@ -82,6 +95,137 @@ test("fetches the second page", async () => { localName: lang3.local_name, otProgress: 0, ntProgress: 0, + aiGlosses: { + status: "unavailable", + lastSyncedAt: null, + }, + }, + ], + }); +}); + +test("includes AI glosses availability and sync metadata", async () => { + const { language: deu } = await languageFactory.build({ + code: "deu", + englishName: "German", + localName: "Deutsch", + }); + const { language: eng } = await languageFactory.build({ + code: "eng", + englishName: "English", + localName: "English", + }); + const { language: fra } = await languageFactory.build({ + code: "fra", + englishName: "French", + localName: "Français", + }); + const { language: spa } = await languageFactory.build({ + code: "spa", + englishName: "Spanish", + localName: "Español", + }); + + await getDb() + .insertInto("ai_gloss_language") + .values([ + { code: "eng", name: "English" }, + { code: "fra", name: "French" }, + { code: "spa", name: "Spanish" }, + ]) + .execute(); + + const engImportedAt = new Date("2026-04-10T00:00:00.000Z"); + const fraImportedAt = new Date("2026-04-05T00:00:00.000Z"); + const fraPendingAt = new Date("2026-04-12T00:00:00.000Z"); + const spaFailedAt = new Date("2026-04-15T00:00:00.000Z"); + + await getDb() + .insertInto("job") + .values([ + { + id: ulid(), + type: TRANSLATION_JOB_TYPES.IMPORT_AI_GLOSSES, + status: JobStatus.Complete, + payload: { languageCode: "eng" }, + created_at: engImportedAt, + updated_at: engImportedAt, + }, + { + id: ulid(), + type: TRANSLATION_JOB_TYPES.IMPORT_AI_GLOSSES, + status: JobStatus.Complete, + payload: { languageCode: "fra" }, + created_at: fraImportedAt, + updated_at: fraImportedAt, + }, + { + id: ulid(), + type: TRANSLATION_JOB_TYPES.IMPORT_AI_GLOSSES, + status: JobStatus.Pending, + payload: { languageCode: "fra" }, + created_at: fraPendingAt, + updated_at: fraPendingAt, + }, + { + id: ulid(), + type: TRANSLATION_JOB_TYPES.IMPORT_AI_GLOSSES, + status: JobStatus.Failed, + payload: { languageCode: "spa" }, + created_at: spaFailedAt, + updated_at: spaFailedAt, + }, + ]) + .execute(); + + const result = await searchLanguagesReadModel({ page: 0, limit: 10 }); + + expect(result).toEqual({ + total: 4, + page: [ + { + code: eng.code, + englishName: eng.english_name, + localName: eng.local_name, + otProgress: 0, + ntProgress: 0, + aiGlosses: { + status: "imported", + lastSyncedAt: engImportedAt, + }, + }, + { + code: fra.code, + englishName: fra.english_name, + localName: fra.local_name, + otProgress: 0, + ntProgress: 0, + aiGlosses: { + status: "in-progress", + lastSyncedAt: fraPendingAt, + }, + }, + { + code: deu.code, + englishName: deu.english_name, + localName: deu.local_name, + otProgress: 0, + ntProgress: 0, + aiGlosses: { + status: "unavailable", + lastSyncedAt: null, + }, + }, + { + code: spa.code, + englishName: spa.english_name, + localName: spa.local_name, + otProgress: 0, + ntProgress: 0, + aiGlosses: { + status: "available", + lastSyncedAt: null, + }, }, ], }); diff --git a/src/ui/admin/readModels/searchLanguagesReadModel.ts b/src/ui/admin/readModels/searchLanguagesReadModel.ts index 693768ba..9fc8a38b 100644 --- a/src/ui/admin/readModels/searchLanguagesReadModel.ts +++ b/src/ui/admin/readModels/searchLanguagesReadModel.ts @@ -1,4 +1,8 @@ +import { sql } from "kysely"; import { getDb } from "@/db"; +import { TRANSLATION_JOB_TYPES } from "@/modules/translation/jobs/jobType"; +import { JobStatus } from "@/shared/jobs/model"; +import { jsonBuildObject } from "kysely/helpers/postgres"; export interface SearchLanguagesReadModelRequest { page: number; @@ -13,6 +17,10 @@ export interface SearchLanguagesReadModel { localName: string; otProgress: number; ntProgress: number; + aiGlosses: { + status: "unavailable" | "available" | "in-progress" | "imported"; + lastSyncedAt: Date | null; + }; }>; } @@ -27,12 +35,45 @@ export async function searchLanguagesReadModel( getDb() .selectFrom("language as l") .leftJoin("language_progress as p", "p.code", "l.code") + .leftJoin("ai_gloss_language", "ai_gloss_language.code", "l.code") + .leftJoinLateral( + (db) => + db + .selectFrom("job") + .where("type", "=", TRANSLATION_JOB_TYPES.IMPORT_AI_GLOSSES) + .where("status", "<>", JobStatus.Failed) + .where((eb) => + eb( + "payload", + "@>", + sql`jsonb_build_object('languageCode', ${eb.ref("l.code")})`, + ), + ) + .orderBy("created_at", "desc") + .limit(1) + .select(["status", "updated_at"]) + .as("ai_import_job"), + (jb) => jb.onTrue(), + ) .select((eb) => [ "l.code", "l.english_name as englishName", "l.local_name as localName", eb.fn.coalesce("p.ot_progress", eb.lit(0)).as("otProgress"), eb.fn.coalesce("p.nt_progress", eb.lit(0)).as("ntProgress"), + jsonBuildObject({ + status: eb + .case() + .when("ai_import_job.status", "=", JobStatus.Complete) + .then(sql.lit("imported" as const)) + .when("ai_import_job.status", "is not", null) + .then(sql.lit("in-progress" as const)) + .when("ai_gloss_language.code", "is not", null) + .then(sql.lit("available" as const)) + .else(sql.lit("unavailable" as const)) + .end(), + lastSyncedAt: eb.ref("ai_import_job.updated_at"), + }).as("aiGlosses"), ]) .orderBy("l.english_name") .offset(options.page * options.limit) @@ -40,5 +81,21 @@ export async function searchLanguagesReadModel( .execute(), ]); - return { total: totalResult?.total ?? 0, page }; + if (page.length === 0) { + return { total: totalResult?.total ?? 0, page: [] }; + } + + return { + total: totalResult?.total ?? 0, + page: page.map((language) => ({ + ...language, + aiGlosses: { + ...language.aiGlosses, + lastSyncedAt: + language.aiGlosses.lastSyncedAt ? + new Date(language.aiGlosses.lastSyncedAt) + : null, + }, + })), + }; } diff --git a/src/ui/admin/routes/_main.languages/index.tsx b/src/ui/admin/routes/_main.languages/index.tsx index dba59cf2..a40783a3 100644 --- a/src/ui/admin/routes/_main.languages/index.tsx +++ b/src/ui/admin/routes/_main.languages/index.tsx @@ -60,6 +60,9 @@ function AdminLanguagesRoute() { NT Progress + + AI Glosses + @@ -79,6 +82,9 @@ function AdminLanguagesRoute() { {(100 * language.otProgress).toFixed(2)}% {(100 * language.ntProgress).toFixed(2)}% + + +