Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/cms/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
## Conventions

- Content types managed via Strapi admin UI. Avoid manual schema file edits.
- GraphQL plugin is the primary API. REST endpoints exist but apps should not use them.
- GraphQL plugin is the primary API for standard CRUD operations.
- **Performance escape hatch**: For bulk aggregate queries where GraphQL's N+1 problem causes connection pool exhaustion (no DataLoader in Strapi v5), custom REST endpoints using raw SQL via knex are permitted. These live in `src/api/{endpoint-name}/` following the standard route/controller/service structure. Current examples: `video-coverage`, `language-geo`.
- API tokens seeded in bootstrap lifecycle using HMAC-SHA512 hashing.
- Media uploads handled by Strapi's default provider (or configured cloud provider).

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Add indexes to Strapi v5 link tables used by custom REST endpoints.
*
* Strapi v5 auto-generates `_lnk` junction tables for content type relations
* but does not create indexes on the foreign key columns. Without indexes,
* JOINs through these tables fall back to sequential scans.
*
* These link tables are joined by:
* - /api/language-geo (country_languages → languages, countries, continents)
* - /api/video-coverage (video_subtitles → videos, languages; video_variants → videos, languages)
*/

const LINK_TABLE_INDEXES = [
// language-geo endpoint
{ table: "country_languages_language_lnk", column: "country_language_id" },
{ table: "country_languages_language_lnk", column: "language_id" },
{ table: "country_languages_country_lnk", column: "country_language_id" },
{ table: "country_languages_country_lnk", column: "country_id" },
{ table: "countries_continent_lnk", column: "country_id" },
{ table: "countries_continent_lnk", column: "continent_id" },
// video-coverage endpoint
{ table: "video_subtitles_video_lnk", column: "video_subtitle_id" },
{ table: "video_subtitles_video_lnk", column: "video_id" },
{ table: "video_subtitles_language_lnk", column: "video_subtitle_id" },
{ table: "video_subtitles_language_lnk", column: "language_id" },
{ table: "video_variants_video_lnk", column: "video_variant_id" },
{ table: "video_variants_video_lnk", column: "video_id" },
{ table: "video_variants_language_lnk", column: "video_variant_id" },
{ table: "video_variants_language_lnk", column: "language_id" },
{ table: "videos_children_lnk", column: "video_id" },
{ table: "videos_children_lnk", column: "inv_video_id" },
{ table: "video_images_video_lnk", column: "video_image_id" },
{ table: "video_images_video_lnk", column: "video_id" },
] as const

/* eslint-disable @typescript-eslint/no-explicit-any */
export async function up(knex: any): Promise<void> {
for (const { table, column } of LINK_TABLE_INDEXES) {
const exists = await knex.schema.hasTable(table)
if (!exists) continue
const indexName = `idx_${table}_${column}`
await knex.raw(
`CREATE INDEX IF NOT EXISTS ${indexName} ON "${table}" ("${column}")`,
)
}
}

export async function down(knex: any): Promise<void> {
for (const { table, column } of LINK_TABLE_INDEXES) {
const indexName = `idx_${table}_${column}`
await knex.raw(`DROP INDEX IF EXISTS ${indexName}`)
}
}
19 changes: 19 additions & 0 deletions apps/cms/src/api/language-geo/controllers/language-geo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Core } from "@strapi/strapi"
import { queryLanguageGeo } from "../services/language-geo"

type StrapiContext = {
status: number
body: unknown
}

export default ({ strapi }: { strapi: Core.Strapi }) => ({
async index(ctx: StrapiContext) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const knex = (strapi.db as any).connection

const data = await queryLanguageGeo(knex)

ctx.status = 200
ctx.body = data
},
})
13 changes: 13 additions & 0 deletions apps/cms/src/api/language-geo/routes/language-geo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default {
routes: [
{
method: "GET",
path: "/language-geo",
handler: "language-geo.index",
config: {
policies: [],
middlewares: [],
},
},
],
}
153 changes: 153 additions & 0 deletions apps/cms/src/api/language-geo/services/language-geo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Language Geo Service
*
* Returns all language, country, and continent data in a single SQL query,
* bypassing GraphQL's N+1 problem (no DataLoader in Strapi v5).
*
* Critical: All queries filter `published_at IS NOT NULL` to avoid
* counting Strapi v5 draft rows.
*/

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type KnexInstance = any

type RawRow = {
lang_core_id: string | null
lang_document_id: string
lang_name: string | null
speakers: number | null
country_core_id: string | null
country_document_id: string
country_name: string | null
continent_core_id: string | null
continent_document_id: string
continent_name: string | null
}

type Continent = {
id: string
name: string
}

type Country = {
id: string
name: string
continentId: string
}

type Language = {
id: string
englishLabel: string
nativeLabel: string
countryIds: string[]
continentIds: string[]
countrySpeakers: Record<string, number>
}

// Must match CmsLanguageGeo in apps/manager/src/app/api/languages/route.ts
type LanguageGeoResult = {
continents: Continent[]
countries: Country[]
languages: Language[]
}

export async function queryLanguageGeo(
knex: KnexInstance,
): Promise<LanguageGeoResult> {
const sql = `
SELECT
l.core_id AS lang_core_id,
l.document_id AS lang_document_id,
l.name AS lang_name,
cl.speakers,
c.core_id AS country_core_id,
c.document_id AS country_document_id,
c.name AS country_name,
ct.core_id AS continent_core_id,
ct.document_id AS continent_document_id,
ct.name AS continent_name
FROM country_languages cl
JOIN country_languages_language_lnk cll ON cll.country_language_id = cl.id
JOIN country_languages_country_lnk clc ON clc.country_language_id = cl.id
JOIN languages l ON l.id = cll.language_id AND l.published_at IS NOT NULL
JOIN countries c ON c.id = clc.country_id AND c.published_at IS NOT NULL
JOIN countries_continent_lnk ccl ON ccl.country_id = c.id
JOIN continents ct ON ct.id = ccl.continent_id AND ct.published_at IS NOT NULL
WHERE cl.published_at IS NOT NULL
`

const result: { rows: RawRow[] } = await knex.raw(sql)

// Aggregate raw rows into the denormalized shape the manager expects
const continentMap = new Map<string, Continent>()
const countryMap = new Map<string, Country>()
const langCountryIds = new Map<string, Set<string>>()
const langContinentIds = new Map<string, Set<string>>()
const langCountrySpeakers = new Map<string, Record<string, number>>()
const langNames = new Map<string, string>()

for (const row of result.rows) {
const continentId = String(
row.continent_core_id ?? row.continent_document_id,
)
const countryId = String(row.country_core_id ?? row.country_document_id)
const langId = String(row.lang_core_id ?? row.lang_document_id)

// Collect unique continents
if (!continentMap.has(continentId)) {
continentMap.set(continentId, {
id: continentId,
name: String(row.continent_name ?? ""),
})
}

// Collect unique countries
if (!countryMap.has(countryId)) {
countryMap.set(countryId, {
id: countryId,
name: String(row.country_name ?? ""),
continentId,
})
}

// Track language name
if (!langNames.has(langId)) {
langNames.set(langId, String(row.lang_name ?? langId))
}

// Track country IDs per language
if (!langCountryIds.has(langId)) langCountryIds.set(langId, new Set())
langCountryIds.get(langId)!.add(countryId)

// Track continent IDs per language
if (!langContinentIds.has(langId)) langContinentIds.set(langId, new Set())
langContinentIds.get(langId)!.add(continentId)

// Aggregate speakers per language per country
const speakers = row.speakers ?? 0
if (speakers > 0) {
if (!langCountrySpeakers.has(langId)) langCountrySpeakers.set(langId, {})
const existing = langCountrySpeakers.get(langId)!
existing[countryId] = (existing[countryId] ?? 0) + speakers
}
}

// Build languages array from all unique language IDs
const languages: Language[] = []
for (const [langId, name] of langNames) {
languages.push({
id: langId,
englishLabel: name,
nativeLabel: name,
countryIds: Array.from(langCountryIds.get(langId) ?? []),
continentIds: Array.from(langContinentIds.get(langId) ?? []),
countrySpeakers: langCountrySpeakers.get(langId) ?? {},
})
}

return {
continents: Array.from(continentMap.values()),
countries: Array.from(countryMap.values()),
languages,
}
}
1 change: 0 additions & 1 deletion apps/cms/src/api/video-coverage/routes/video-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ export default {
path: "/video-coverage",
handler: "video-coverage.index",
config: {
auth: false,
policies: [],
middlewares: [],
},
Expand Down
Loading
Loading