diff --git a/controller/Dockerfile b/controller/Dockerfile index f0237a0..5309544 100644 --- a/controller/Dockerfile +++ b/controller/Dockerfile @@ -11,7 +11,17 @@ ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ AzureFunctionsJobHost__Logging__Console__IsEnabled=true WORKDIR /home/site/wwwroot +ENV PNPM_HOME=/usr/local/bin + +COPY ./package.json /home/site/wwwroot +RUN pnpm install +RUN pnpm add --global azure-functions-core-tools@^4.0.5455 COPY . . +# I have no clue why, but when I included azure core tools as part of the controller dependencies using the core tools node package, the func command reliably broke, but not when installed globally +# Enable `pnpm add --global` on Alpine Linux by setting +# home location environment variable to a location already in $PATH +# https://github.com/pnpm/pnpm/issues/784#issuecomment-1518582235 + RUN apt-get update && \ @@ -20,12 +30,6 @@ RUN apt-get update && \ ARG DATABASE_URL -# I have no clue why, but when I included azure core tools as part of the controller dependencies using the core tools node package, the func command reliably broke, but not when installed globally -# Enable `pnpm add --global` on Alpine Linux by setting -# home location environment variable to a location already in $PATH -# https://github.com/pnpm/pnpm/issues/784#issuecomment-1518582235 -ENV PNPM_HOME=/usr/local/bin -RUN pnpm add --global azure-functions-core-tools@^4.0.5455 -RUN pnpm install && \ - pnpm run build + +RUN pnpm run build diff --git a/controller/drizzle/0021_first_ink.sql b/controller/drizzle/0021_first_ink.sql new file mode 100644 index 0000000..b38a174 --- /dev/null +++ b/controller/drizzle/0021_first_ink.sql @@ -0,0 +1,16 @@ +CREATE OR REPLACE VIEW source_zips AS +SELECT c.*, CONCAT(g.repo_url, '/archive/master.zip') AS zip_url, COUNT(DISTINCT srm.book_slug) AS unique_book_slugs_count, +m.show_on_biel, m.status AS meta_status, l.national_name, l.english_name + +FROM content c +JOIN rendering r ON r.content_id = c.id +JOIN scriptural_rendering_metadata srm ON srm.rendering_id = r.id +JOIN git_repo g ON g.id = c.git_id +JOIN wa_content_meta m ON m.content_id = c.id +JOIN language l on c.language_id = l.ietf_code +WHERE c.domain = 'scripture' +AND m.show_on_biel = false +AND m.status = 'Active' +AND c.git_id IS NOT NULL +GROUP BY c.id, CONCAT(g.repo_url, '/archive/master.zip'), m.id, l.national_name, l.english_name +HAVING COUNT(DISTINCT srm.book_slug) > 26 \ No newline at end of file diff --git a/controller/drizzle/0022_stiff_raider.sql b/controller/drizzle/0022_stiff_raider.sql new file mode 100644 index 0000000..dc707f8 --- /dev/null +++ b/controller/drizzle/0022_stiff_raider.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS "localization" ( + "ietf_code" varchar NOT NULL, + "key" varchar NOT NULL, + "value" text NOT NULL, + CONSTRAINT "localization_pkey" PRIMARY KEY("ietf_code","key") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "localization" ADD CONSTRAINT "localization_ietf_code_language_ietf_code_fk" FOREIGN KEY ("ietf_code") REFERENCES "language"("ietf_code") ON DELETE cascade ON UPDATE cascade; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/controller/pnpm-lock.yaml b/controller/pnpm-lock.yaml index bfd5914..f3b1c2c 100644 --- a/controller/pnpm-lock.yaml +++ b/controller/pnpm-lock.yaml @@ -1328,6 +1328,11 @@ packages: engines: {node: ">= 0.6"} dev: false + /cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + dev: false + /cookiejar@2.1.4: resolution: { diff --git a/controller/src/db/utils/getMocks.ts b/controller/src/db/utils/getMocks.ts index 560c403..01ccb00 100644 --- a/controller/src/db/utils/getMocks.ts +++ b/controller/src/db/utils/getMocks.ts @@ -112,7 +112,7 @@ export function getMockedContent() { // scripture" | "gloss" | "parascriptural" | "peripheral const fakeIdWord = faker.word.words(1); const mockedContent: apiKeys = { - id: fakeIdWord, + // id: fakeIdWord, namespace: "wacs", domain: faker.helpers.arrayElement([ "scripture", diff --git a/controller/src/docs/openApi.ts b/controller/src/docs/openApi.ts index d827c29..5d73c6c 100644 --- a/controller/src/docs/openApi.ts +++ b/controller/src/docs/openApi.ts @@ -139,7 +139,7 @@ registry.registerPath({ // apiValidators.langDelete request: { body: { - description: "A list of ietfCodes corresponding to languages to delete", + description: "", content: { "applications/json": { schema: apiValidators.countryDelete, @@ -292,8 +292,7 @@ registry.registerPath({ // apiValidators.langDelete request: { body: { - description: - "A list of ietfCodes corresponding to languages to delete. Whereas inserts are namespaced, as: namespace-id, the client is expected to namespace (lowered, trimmed) to delete its ids", + description: "", content: { "applications/json": { schema: apiValidators.contentDelete, diff --git a/controller/src/docs/open_api_v2.json b/controller/src/docs/open_api_v2.json index 4e9836d..76fff8e 100644 --- a/controller/src/docs/open_api_v2.json +++ b/controller/src/docs/open_api_v2.json @@ -17,7 +17,7 @@ ], "parameters": [ { - "description": "A list of ietfCodes corresponding to languages to delete. Whereas inserts are namespaced, as: namespace-id, the client is expected to namespace (lowered, trimmed) to delete its ids", + "description": "", "in": "body", "name": "body", "schema": { @@ -198,7 +198,7 @@ ], "parameters": [ { - "description": "A list of ietfCodes corresponding to languages to delete", + "description": "", "in": "body", "name": "body", "schema": { @@ -1007,25 +1007,24 @@ "content": { "example": [ { - "createdOn": "2024-09-07T16:34:28.186Z", + "createdOn": "2025-03-24T21:09:44.194Z", "domain": "peripheral", "gitEntry": { "namespace": "wacs", - "repoName": "violet", - "repoUrl": "https://blind-committee.name/", - "username": "Christ_Kozey82" + "repoName": "clipper", + "repoUrl": "https://knotty-headquarters.net/", + "username": "Ebba_Morissette" }, - "id": "yowza", - "languageId": "urs", + "languageId": "kht", "level": "medium", "meta": { - "showOnBiel": true, + "showOnBiel": false, "status": "not approved" }, - "modifiedOn": "2024-02-10T08:11:54.917Z", - "name": "notes", + "modifiedOn": "2025-03-28T14:29:04.763Z", + "name": "ulb", "namespace": "wacs", - "resourceType": "reg", + "resourceType": "tq", "type": "text" } ], @@ -1133,13 +1132,13 @@ "country": { "example": [ { - "alpha2": "BM", - "alpha3": "BRN", - "createdOn": "2023-05-24T02:59:32.846Z", - "modifiedOn": "2024-04-15T06:49:17.349Z", - "name": "Switzerland", - "population": 1712482197, - "regionName": "Asia" + "alpha2": "BN", + "alpha3": "CRI", + "createdOn": "2023-12-13T01:16:56.564Z", + "modifiedOn": "2023-08-19T09:35:29.012Z", + "name": "Samoa", + "population": 461394755, + "regionName": "Austrailia" } ], "items": { @@ -1186,9 +1185,9 @@ "example": [ { "contentId": "wacs-user-repo", - "repoName": "yuck", - "repoUrl": "https://confused-plaintiff.info", - "username": "Deborah34" + "repoName": "readily", + "repoUrl": "https://gummy-waveform.biz/", + "username": "Esta_Bayer" } ], "items": { @@ -1222,22 +1221,27 @@ "language": { "example": [ { - "allCountryAlpha2": [], + "allCountryAlpha2": [ + "SS", + "CR" + ], "alternateNames": [ - "flaky" + "inside", + "consequently", + "offbeat" ], - "createdOn": "2024-10-15T14:47:18.418Z", - "direction": "rtl", - "englishName": "finally", - "homeCountryAlpha2": "GQ", - "id": "WVpTRyrzAg", - "ietfCode": "rrj", - "isOralLanguage": false, - "iso6393": "cspejjbojt", - "nationalName": "leak", + "createdOn": "2025-03-12T15:30:57.764Z", + "direction": "ltr", + "englishName": "unless", + "homeCountryAlpha2": "LA", + "id": "fqcyKyFEoH", + "ietfCode": "ats", + "isOralLanguage": true, + "iso6393": "nwhornsr", + "nationalName": "wisdom", "waLangMeta": { - "isGateway": false, - "showOnBiel": true + "isGateway": true, + "showOnBiel": false } } ], @@ -1342,9 +1346,9 @@ "region": { "example": [ { - "createdOn": "2023-05-17T02:58:14.426Z", - "modifiedOn": "2024-12-20T05:48:25.914Z", - "name": "Asia" + "createdOn": "2023-07-27T23:47:58.068Z", + "modifiedOn": "2025-01-20T19:09:33.551Z", + "name": "Africa" } ], "items": { @@ -1376,46 +1380,46 @@ [ { "contentId": "user-repo", - "createdAt": "2023-11-06T13:25:33.395Z", - "fileSizeBytes": 1344618048258048, - "fileType": "zip", - "modifiedOn": "2025-01-20T10:22:31.513Z", + "createdAt": "2024-01-25T13:36:54.975Z", + "fileSizeBytes": 7289338714914816, + "fileType": "mp3", + "modifiedOn": "2023-08-09T17:50:58.948Z", "namespace": "wacs", "scripturalMeta": { "bookName": "1 Jean", "bookSlug": "1JN", - "chapter": 0, + "chapter": 1, "isWholeBook": true, "isWholeProject": false, - "tempId": "8419730d-55ac-463c-9bea-a42565eb746c" + "tempId": "yxznk9pe8qq53w5g97yiken1" }, - "tempId": "8419730d-55ac-463c-9bea-a42565eb746c", - "url": "https://imperturbable-bog.info/" + "tempId": "yxznk9pe8qq53w5g97yiken1", + "url": "https://embarrassed-frog.info" } ], [ { "contentId": "user-repo", - "createdAt": "2023-05-14T14:55:35.579Z", - "fileSizeBytes": 4763748757667840, - "fileType": "web", - "modifiedOn": "2023-09-16T18:28:07.168Z", + "createdAt": "2024-12-14T00:21:21.892Z", + "fileSizeBytes": 2049820787212288, + "fileType": "zip", + "modifiedOn": "2023-12-23T20:12:25.204Z", "namespace": "wacs", "nonScripturalMeta": { "additionalData": "A json field", "name": "nonScripturalName", - "tempId": "6c0a0baf-268e-4c4c-803a-48c0bf665fca" + "tempId": "n9bei7lcw4ey9gvcnu51rcrb" }, "scripturalMeta": { "bookName": "1 Jean", "bookSlug": "1JN", - "chapter": 2, - "isWholeBook": true, - "isWholeProject": true, - "tempId": "6c0a0baf-268e-4c4c-803a-48c0bf665fca" + "chapter": 4, + "isWholeBook": false, + "isWholeProject": false, + "tempId": "n9bei7lcw4ey9gvcnu51rcrb" }, - "tempId": "6c0a0baf-268e-4c4c-803a-48c0bf665fca", - "url": "https://demanding-frenzy.org/" + "tempId": "n9bei7lcw4ey9gvcnu51rcrb", + "url": "https://creative-identity.net/" } ] ], diff --git a/controller/src/docs/open_api_v3.json b/controller/src/docs/open_api_v3.json index 35591d2..16ccff6 100644 --- a/controller/src/docs/open_api_v3.json +++ b/controller/src/docs/open_api_v3.json @@ -1 +1 @@ -{"openapi":"3.0.0","info":{"version":"1.0.0","title":"My API","description":"This is the API"},"servers":[{"url":"/"}],"components":{"schemas":{"language":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"ietfCode":{"type":"string"},"nationalName":{"type":"string"},"englishName":{"type":"string"},"direction":{"type":"string","enum":["ltr","rtl"]},"iso6393":{"type":"string","nullable":true},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"isOralLanguage":{"type":"boolean","nullable":true},"homeCountryAlpha2":{"type":"string"},"allCountryAlpha2":{"type":"array","items":{"type":"string"}},"alternateNames":{"type":"array","items":{"type":"string"}},"waLangMeta":{"type":"object","properties":{"isGateway":{"type":"boolean"},"showOnBiel":{"type":"boolean"}},"required":["isGateway","showOnBiel"]},"gatewayIetf":{"type":"string"}},"required":["id","ietfCode","nationalName","englishName","direction","homeCountryAlpha2"]},"example":[{"id":"WVpTRyrzAg","homeCountryAlpha2":"GQ","allCountryAlpha2":[],"direction":"rtl","englishName":"finally","ietfCode":"rrj","nationalName":"leak","alternateNames":["flaky"],"createdOn":"2024-10-15T14:47:18.418Z","iso6393":"cspejjbojt","isOralLanguage":false,"waLangMeta":{"isGateway":false,"showOnBiel":true}}]},"country":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"name":{"type":"string"},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"alpha2":{"type":"string"},"alpha3":{"type":"string","nullable":true},"population":{"type":"number","nullable":true},"regionName":{"type":"string"}},"required":["name","alpha2","regionName"]},"example":[{"name":"Switzerland","createdOn":"2023-05-24T02:59:32.846Z","modifiedOn":"2024-04-15T06:49:17.349Z","alpha2":"BM","alpha3":"BRN","population":1712482197,"regionName":"Asia"}]},"content":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"languageId":{"type":"string","nullable":true},"name":{"type":"string","maxLength":256},"namespace":{"type":"string"},"type":{"type":"string","enum":["text","audio","video","braille"]},"domain":{"type":"string","nullable":true,"enum":["scripture","gloss","parascriptural","peripheral"]},"resourceType":{"type":"string","nullable":true},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"level":{"type":"string","nullable":true},"meta":{"type":"object","properties":{"id":{"type":"number"},"showOnBiel":{"type":"boolean"},"status":{"type":"string"}},"required":["showOnBiel","status"]},"gitEntry":{"type":"object","properties":{"id":{"type":"number"},"username":{"type":"string"},"repoName":{"type":"string"},"repoUrl":{"type":"string"}},"required":["username","repoName","repoUrl"]}},"required":["name","namespace","type"]},"example":[{"id":"yowza","namespace":"wacs","domain":"peripheral","createdOn":"2024-09-07T16:34:28.186Z","modifiedOn":"2024-02-10T08:11:54.917Z","languageId":"urs","level":"medium","name":"notes","resourceType":"reg","type":"text","meta":{"showOnBiel":true,"status":"not approved"},"gitEntry":{"username":"Christ_Kozey82","repoName":"violet","repoUrl":"https://blind-committee.name/","namespace":"wacs"}}]},"region":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"name":{"type":"string"},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true}},"required":["name"]},"example":[{"name":"Asia","createdOn":"2023-05-17T02:58:14.426Z","modifiedOn":"2024-12-20T05:48:25.914Z"}]},"git":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"contentId":{"type":"string"},"username":{"type":"string"},"repoName":{"type":"string"},"repoUrl":{"type":"string"}},"required":["contentId","username","repoName","repoUrl"]},"example":[{"contentId":"wacs-user-repo","username":"Deborah34","repoName":"yuck","repoUrl":"https://confused-plaintiff.info"}]},"rendering":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"contentId":{"type":"string"},"fileType":{"type":"string"},"fileSizeBytes":{"type":"number","nullable":true,"minimum":0},"url":{"type":"string"},"hash":{"type":"string","nullable":true},"createdAt":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"tempId":{"type":"string"},"namespace":{"type":"string"},"scripturalMeta":{"type":"object","properties":{"id":{"type":"number"},"renderingId":{"type":"number"},"bookSlug":{"type":"string","nullable":true},"bookName":{"type":"string","nullable":true},"chapter":{"type":"number","nullable":true},"isWholeBook":{"type":"boolean"},"isWholeProject":{"type":"boolean"},"sort":{"type":"number","nullable":true},"tempId":{"type":"string"}},"required":["isWholeBook","isWholeProject","tempId"]},"nonScripturalMeta":{"type":"object","properties":{"id":{"type":"number"},"renderingId":{"type":"number"},"name":{"type":"string","nullable":true,"maxLength":256},"additionalData":{"nullable":true},"tempId":{"type":"string"}},"required":["tempId"]}},"required":["contentId","fileType","url","tempId","namespace"]},"examples":[[{"tempId":"8419730d-55ac-463c-9bea-a42565eb746c","contentId":"user-repo","namespace":"wacs","fileType":"zip","scripturalMeta":{"tempId":"8419730d-55ac-463c-9bea-a42565eb746c","isWholeBook":true,"isWholeProject":false,"bookName":"1 Jean","bookSlug":"1JN","chapter":0},"url":"https://imperturbable-bog.info/","fileSizeBytes":1344618048258048,"createdAt":"2023-11-06T13:25:33.395Z","modifiedOn":"2025-01-20T10:22:31.513Z"}],[{"tempId":"6c0a0baf-268e-4c4c-803a-48c0bf665fca","contentId":"user-repo","namespace":"wacs","fileType":"web","scripturalMeta":{"tempId":"6c0a0baf-268e-4c4c-803a-48c0bf665fca","isWholeBook":true,"isWholeProject":true,"bookName":"1 Jean","bookSlug":"1JN","chapter":2},"url":"https://demanding-frenzy.org/","fileSizeBytes":4763748757667840,"createdAt":"2023-05-14T14:55:35.579Z","modifiedOn":"2023-09-16T18:28:07.168Z","nonScripturalMeta":{"tempId":"6c0a0baf-268e-4c4c-803a-48c0bf665fca","name":"nonScripturalName","additionalData":"A json field"}}]]},"apiError":{"type":"object","properties":{"message":{"type":"string"},"err":{"type":"object","properties":{"issues":{"type":"array","items":{"nullable":true}},"didErr":{"type":"boolean"},"name":{"type":"string"}}},"addlErrs":{"type":"array","items":{"type":"object","properties":{"message":{"type":"string"},"name":{"type":"string"}},"required":["message","name"]}}},"required":["message","err","addlErrs"]},"okRes":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean","default":true}},"required":["message"]}},"parameters":{}},"paths":{"/api/country":{"post":{"operationId":"CreateCountry","description":"Add one or more country","requestBody":{"description":"A list of countries","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/country"}}}},"responses":{"200":{"description":"Object with inserted country data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetCountry","description":"Get country","responses":{"200":{"description":"All countries","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"name":{"type":"string"},"worldRegionId":{"type":"number"},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"alpha2":{"type":"string"},"alpha3":{"type":"string","nullable":true},"population":{"type":"number","nullable":true}},"required":["id","name","worldRegionId","createdOn","modifiedOn","alpha2","alpha3","population"]}}},"required":["message","data"]}}}}}},"delete":{"operationId":"DeleteCountry","description":"Delete one or more countries","requestBody":{"description":"A list of ietfCodes corresponding to languages to delete","content":{"applications/json":{"schema":{"type":"object","properties":{"alpha2Codes":{"type":"array","items":{"type":"string"}}},"required":["alpha2Codes"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}},"/api/language":{"post":{"operationId":"CreateLanguage","description":"Add one or more language","requestBody":{"description":"A list of languages","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/language"}}}},"responses":{"200":{"description":"Object with inserted language data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetLanguage","description":"Get language","responses":{"200":{"description":"All languages","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"ietfCode":{"type":"string"},"nationalName":{"type":"string"},"englishName":{"type":"string"},"direction":{"type":"string","enum":["ltr","rtl"]},"iso6393":{"type":"string","nullable":true},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"isOralLanguage":{"type":"boolean","nullable":true},"homeCountryAlpha2":{"type":"string"}},"required":["id","ietfCode","nationalName","englishName","direction","iso6393","createdOn","modifiedOn","isOralLanguage","homeCountryAlpha2"]}}},"required":["message","ok","data"]}}}}}},"delete":{"operationId":"DeleteLanguage","description":"Delete one or more languages","requestBody":{"description":"A list of ietfCodes corresponding to languages to delete","content":{"applications/json":{"schema":{"type":"object","properties":{"ietfCodes":{"type":"array","items":{"type":"string"}}},"required":["ietfCodes"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}},"/api/content":{"post":{"operationId":"CreateContent","description":"Add one or more repo/content","requestBody":{"description":"A list of projects/content","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/content"}}}},"responses":{"200":{"description":"Object with inserted content data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetContent","description":"Get content","responses":{"200":{"description":"All content","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","maxLength":256},"languageId":{"type":"string","nullable":true},"name":{"type":"string","maxLength":256},"namespace":{"type":"string","maxLength":256},"type":{"type":"string","enum":["text","audio","video","braille"]},"domain":{"type":"string","nullable":true,"enum":["scripture","gloss","parascriptural","peripheral"]},"resourceType":{"type":"string","nullable":true},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"level":{"type":"string","nullable":true}},"required":["id","languageId","name","namespace","type","domain","resourceType","createdOn","modifiedOn","level"]}}},"required":["message","ok","data"]}}}}}},"delete":{"operationId":"DeleteContent","description":"Delete one or more content/repo","requestBody":{"description":"A list of ietfCodes corresponding to languages to delete. Whereas inserts are namespaced, as: namespace-id, the client is expected to namespace (lowered, trimmed) to delete its ids","content":{"applications/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}},"required":["ids"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}},"/api/region":{"post":{"operationId":"CreateRegion","description":"Add one or more regions","requestBody":{"description":"A list of regions","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/region"}}}},"responses":{"200":{"description":"Object with inserted region data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetRegion","description":"Get region","responses":{"200":{"description":"All regions","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"name":{"type":"string"},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true}},"required":["id","name","createdOn","modifiedOn"]}}},"required":["message","ok","data"]}}}}}},"delete":{"operationId":"DeleteRegion","description":"Delete one or more regions","requestBody":{"description":"A list of regions by their regionName corresponding to regions to delete","content":{"applications/json":{"schema":{"type":"object","properties":{"regionNames":{"type":"array","items":{"type":"string"}}},"required":["regionNames"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}},"/api/git":{"post":{"operationId":"CreateGit","description":"Add one or more git entries","requestBody":{"description":"A list of git entries to add","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/git"}}}},"responses":{"200":{"description":"Confirmation of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetGit","description":"Get git entries","responses":{"200":{"description":"All git entries","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"contentId":{"type":"string"},"username":{"type":"string"},"repoName":{"type":"string"},"repoUrl":{"type":"string"}},"required":["id","contentId","username","repoName","repoUrl"]}}},"required":["message","ok","data"]}}}}}},"delete":{"operationId":"DeleteGit","description":"Delete one or git entries","requestBody":{"description":"A list of username and repo (composite key) used to delete git repos","content":{"applications/json":{"schema":{"type":"object","properties":{"userRepo":{"type":"array","items":{"type":"object","properties":{"username":{"type":"string"},"repo":{"type":"string"}},"required":["username","repo"]}}},"required":["userRepo"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}},"/api/rendering":{"post":{"operationId":"CreateRendering","description":"Add one or more renders/links of content","requestBody":{"description":"A list of renders/links to add","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/rendering"}}}},"responses":{"200":{"description":"Confirmation of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetRendering","description":"Get renderings","responses":{"200":{"description":"All renderings","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"contentId":{"type":"string"},"fileType":{"type":"string"},"fileSizeBytes":{"type":"number","nullable":true},"url":{"type":"string"},"hash":{"type":"string","nullable":true},"createdAt":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true}},"required":["id","contentId","fileType","fileSizeBytes","url","hash","createdAt","modifiedOn"]}}},"required":["message","ok","data"]}}}}}},"delete":{"operationId":"DeleteRendering","description":"Delete one or more renderings","requestBody":{"description":"A list of namespaces and content ids (e.g. namespace=wacs, contentId=user-repo to use to delete renderings","content":{"applications/json":{"schema":{"type":"object","properties":{"contentIds":{"type":"array","items":{"type":"string"}}},"required":["contentIds"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}}}} \ No newline at end of file +{"openapi":"3.0.0","info":{"version":"1.0.0","title":"My API","description":"This is the API"},"servers":[{"url":"/"}],"components":{"schemas":{"language":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"ietfCode":{"type":"string"},"nationalName":{"type":"string"},"englishName":{"type":"string"},"direction":{"type":"string","enum":["ltr","rtl"]},"iso6393":{"type":"string","nullable":true},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"isOralLanguage":{"type":"boolean","nullable":true},"homeCountryAlpha2":{"type":"string"},"allCountryAlpha2":{"type":"array","items":{"type":"string"}},"alternateNames":{"type":"array","items":{"type":"string"}},"waLangMeta":{"type":"object","properties":{"isGateway":{"type":"boolean"},"showOnBiel":{"type":"boolean"}},"required":["isGateway","showOnBiel"]},"gatewayIetf":{"type":"string"}},"required":["id","ietfCode","nationalName","englishName","direction","homeCountryAlpha2"]},"example":[{"id":"fqcyKyFEoH","homeCountryAlpha2":"LA","allCountryAlpha2":["SS","CR"],"direction":"ltr","englishName":"unless","ietfCode":"ats","nationalName":"wisdom","alternateNames":["inside","consequently","offbeat"],"createdOn":"2025-03-12T15:30:57.764Z","iso6393":"nwhornsr","isOralLanguage":true,"waLangMeta":{"isGateway":true,"showOnBiel":false}}]},"country":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"name":{"type":"string"},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"alpha2":{"type":"string"},"alpha3":{"type":"string","nullable":true},"population":{"type":"number","nullable":true},"regionName":{"type":"string"}},"required":["name","alpha2","regionName"]},"example":[{"name":"Samoa","createdOn":"2023-12-13T01:16:56.564Z","modifiedOn":"2023-08-19T09:35:29.012Z","alpha2":"BN","alpha3":"CRI","population":461394755,"regionName":"Austrailia"}]},"content":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"languageId":{"type":"string","nullable":true},"name":{"type":"string","maxLength":256},"namespace":{"type":"string"},"type":{"type":"string","enum":["text","audio","video","braille"]},"domain":{"type":"string","nullable":true,"enum":["scripture","gloss","parascriptural","peripheral"]},"resourceType":{"type":"string","nullable":true},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"level":{"type":"string","nullable":true},"meta":{"type":"object","properties":{"id":{"type":"number"},"showOnBiel":{"type":"boolean"},"status":{"type":"string"}},"required":["showOnBiel","status"]},"gitEntry":{"type":"object","properties":{"id":{"type":"number"},"username":{"type":"string"},"repoName":{"type":"string"},"repoUrl":{"type":"string"}},"required":["username","repoName","repoUrl"]}},"required":["name","namespace","type"]},"example":[{"namespace":"wacs","domain":"peripheral","createdOn":"2025-03-24T21:09:44.194Z","modifiedOn":"2025-03-28T14:29:04.763Z","languageId":"kht","level":"medium","name":"ulb","resourceType":"tq","type":"text","meta":{"showOnBiel":false,"status":"not approved"},"gitEntry":{"username":"Ebba_Morissette","repoName":"clipper","repoUrl":"https://knotty-headquarters.net/","namespace":"wacs"}}]},"region":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"name":{"type":"string"},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true}},"required":["name"]},"example":[{"name":"Africa","createdOn":"2023-07-27T23:47:58.068Z","modifiedOn":"2025-01-20T19:09:33.551Z"}]},"git":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"contentId":{"type":"string"},"username":{"type":"string"},"repoName":{"type":"string"},"repoUrl":{"type":"string"}},"required":["contentId","username","repoName","repoUrl"]},"example":[{"contentId":"wacs-user-repo","username":"Esta_Bayer","repoName":"readily","repoUrl":"https://gummy-waveform.biz/"}]},"rendering":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"contentId":{"type":"string"},"fileType":{"type":"string"},"fileSizeBytes":{"type":"number","nullable":true,"minimum":0},"url":{"type":"string"},"hash":{"type":"string","nullable":true},"createdAt":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"tempId":{"type":"string"},"namespace":{"type":"string"},"scripturalMeta":{"type":"object","properties":{"id":{"type":"number"},"renderingId":{"type":"number"},"bookSlug":{"type":"string","nullable":true},"bookName":{"type":"string","nullable":true},"chapter":{"type":"number","nullable":true},"isWholeBook":{"type":"boolean"},"isWholeProject":{"type":"boolean"},"sort":{"type":"number","nullable":true},"tempId":{"type":"string"}},"required":["isWholeBook","isWholeProject","tempId"]},"nonScripturalMeta":{"type":"object","properties":{"id":{"type":"number"},"renderingId":{"type":"number"},"name":{"type":"string","nullable":true,"maxLength":256},"additionalData":{"nullable":true},"tempId":{"type":"string"}},"required":["tempId"]}},"required":["contentId","fileType","url","tempId","namespace"]},"examples":[[{"tempId":"yxznk9pe8qq53w5g97yiken1","contentId":"user-repo","namespace":"wacs","fileType":"mp3","scripturalMeta":{"tempId":"yxznk9pe8qq53w5g97yiken1","isWholeBook":true,"isWholeProject":false,"bookName":"1 Jean","bookSlug":"1JN","chapter":1},"url":"https://embarrassed-frog.info","fileSizeBytes":7289338714914816,"createdAt":"2024-01-25T13:36:54.975Z","modifiedOn":"2023-08-09T17:50:58.948Z"}],[{"tempId":"n9bei7lcw4ey9gvcnu51rcrb","contentId":"user-repo","namespace":"wacs","fileType":"zip","scripturalMeta":{"tempId":"n9bei7lcw4ey9gvcnu51rcrb","isWholeBook":false,"isWholeProject":false,"bookName":"1 Jean","bookSlug":"1JN","chapter":4},"url":"https://creative-identity.net/","fileSizeBytes":2049820787212288,"createdAt":"2024-12-14T00:21:21.892Z","modifiedOn":"2023-12-23T20:12:25.204Z","nonScripturalMeta":{"tempId":"n9bei7lcw4ey9gvcnu51rcrb","name":"nonScripturalName","additionalData":"A json field"}}]]},"apiError":{"type":"object","properties":{"message":{"type":"string"},"err":{"type":"object","properties":{"issues":{"type":"array","items":{"nullable":true}},"didErr":{"type":"boolean"},"name":{"type":"string"}}},"addlErrs":{"type":"array","items":{"type":"object","properties":{"message":{"type":"string"},"name":{"type":"string"}},"required":["message","name"]}}},"required":["message","err","addlErrs"]},"okRes":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean","default":true}},"required":["message"]}},"parameters":{}},"paths":{"/api/country":{"post":{"operationId":"CreateCountry","description":"Add one or more country","requestBody":{"description":"A list of countries","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/country"}}}},"responses":{"200":{"description":"Object with inserted country data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetCountry","description":"Get country","responses":{"200":{"description":"All countries","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"name":{"type":"string"},"worldRegionId":{"type":"number"},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"alpha2":{"type":"string"},"alpha3":{"type":"string","nullable":true},"population":{"type":"number","nullable":true}},"required":["id","name","worldRegionId","createdOn","modifiedOn","alpha2","alpha3","population"]}}},"required":["message","data"]}}}}}},"delete":{"operationId":"DeleteCountry","description":"Delete one or more countries","requestBody":{"description":"","content":{"applications/json":{"schema":{"type":"object","properties":{"alpha2Codes":{"type":"array","items":{"type":"string"}}},"required":["alpha2Codes"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}},"/api/language":{"post":{"operationId":"CreateLanguage","description":"Add one or more language","requestBody":{"description":"A list of languages","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/language"}}}},"responses":{"200":{"description":"Object with inserted language data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetLanguage","description":"Get language","responses":{"200":{"description":"All languages","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"ietfCode":{"type":"string"},"nationalName":{"type":"string"},"englishName":{"type":"string"},"direction":{"type":"string","enum":["ltr","rtl"]},"iso6393":{"type":"string","nullable":true},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"isOralLanguage":{"type":"boolean","nullable":true},"homeCountryAlpha2":{"type":"string"}},"required":["id","ietfCode","nationalName","englishName","direction","iso6393","createdOn","modifiedOn","isOralLanguage","homeCountryAlpha2"]}}},"required":["message","ok","data"]}}}}}},"delete":{"operationId":"DeleteLanguage","description":"Delete one or more languages","requestBody":{"description":"A list of ietfCodes corresponding to languages to delete","content":{"applications/json":{"schema":{"type":"object","properties":{"ietfCodes":{"type":"array","items":{"type":"string"}}},"required":["ietfCodes"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}},"/api/content":{"post":{"operationId":"CreateContent","description":"Add one or more repo/content","requestBody":{"description":"A list of projects/content","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/content"}}}},"responses":{"200":{"description":"Object with inserted content data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetContent","description":"Get content","responses":{"200":{"description":"All content","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","maxLength":256},"languageId":{"type":"string","nullable":true},"name":{"type":"string","maxLength":256},"namespace":{"type":"string","maxLength":256},"type":{"type":"string","enum":["text","audio","video","braille"]},"domain":{"type":"string","nullable":true,"enum":["scripture","gloss","parascriptural","peripheral"]},"resourceType":{"type":"string","nullable":true},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true},"level":{"type":"string","nullable":true}},"required":["id","languageId","name","namespace","type","domain","resourceType","createdOn","modifiedOn","level"]}}},"required":["message","ok","data"]}}}}}},"delete":{"operationId":"DeleteContent","description":"Delete one or more content/repo","requestBody":{"description":"","content":{"applications/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}},"required":["ids"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}},"/api/region":{"post":{"operationId":"CreateRegion","description":"Add one or more regions","requestBody":{"description":"A list of regions","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/region"}}}},"responses":{"200":{"description":"Object with inserted region data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetRegion","description":"Get region","responses":{"200":{"description":"All regions","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"name":{"type":"string"},"createdOn":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true}},"required":["id","name","createdOn","modifiedOn"]}}},"required":["message","ok","data"]}}}}}},"delete":{"operationId":"DeleteRegion","description":"Delete one or more regions","requestBody":{"description":"A list of regions by their regionName corresponding to regions to delete","content":{"applications/json":{"schema":{"type":"object","properties":{"regionNames":{"type":"array","items":{"type":"string"}}},"required":["regionNames"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}},"/api/git":{"post":{"operationId":"CreateGit","description":"Add one or more git entries","requestBody":{"description":"A list of git entries to add","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/git"}}}},"responses":{"200":{"description":"Confirmation of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetGit","description":"Get git entries","responses":{"200":{"description":"All git entries","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"contentId":{"type":"string"},"username":{"type":"string"},"repoName":{"type":"string"},"repoUrl":{"type":"string"}},"required":["id","contentId","username","repoName","repoUrl"]}}},"required":["message","ok","data"]}}}}}},"delete":{"operationId":"DeleteGit","description":"Delete one or git entries","requestBody":{"description":"A list of username and repo (composite key) used to delete git repos","content":{"applications/json":{"schema":{"type":"object","properties":{"userRepo":{"type":"array","items":{"type":"object","properties":{"username":{"type":"string"},"repo":{"type":"string"}},"required":["username","repo"]}}},"required":["userRepo"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}},"/api/rendering":{"post":{"operationId":"CreateRendering","description":"Add one or more renders/links of content","requestBody":{"description":"A list of renders/links to add","content":{"applications/json":{"schema":{"$ref":"#/components/schemas/rendering"}}}},"responses":{"200":{"description":"Confirmation of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}},"get":{"operationId":"GetRendering","description":"Get renderings","responses":{"200":{"description":"All renderings","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"number"},"contentId":{"type":"string"},"fileType":{"type":"string"},"fileSizeBytes":{"type":"number","nullable":true},"url":{"type":"string"},"hash":{"type":"string","nullable":true},"createdAt":{"type":"string","nullable":true},"modifiedOn":{"type":"string","nullable":true}},"required":["id","contentId","fileType","fileSizeBytes","url","hash","createdAt","modifiedOn"]}}},"required":["message","ok","data"]}}}}}},"delete":{"operationId":"DeleteRendering","description":"Delete one or more renderings","requestBody":{"description":"A list of namespaces and content ids (e.g. namespace=wacs, contentId=user-repo to use to delete renderings","content":{"applications/json":{"schema":{"type":"object","properties":{"contentIds":{"type":"array","items":{"type":"string"}}},"required":["contentIds"]}}}},"responses":{"200":{"description":"confirmation message of successful operation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/okRes"}}}},"400":{"description":"Error with details of improper request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/apiError"}}}}}}}}} \ No newline at end of file diff --git a/controller/src/functions/audio-renderings-bus.ts b/controller/src/functions/audio-renderings-bus.ts new file mode 100644 index 0000000..bfb10f8 --- /dev/null +++ b/controller/src/functions/audio-renderings-bus.ts @@ -0,0 +1,251 @@ +import {z} from "zod"; +import {app, InvocationContext} from "@azure/functions"; +import {checkContentExists} from "../utils"; +import {createId} from "@paralleldrive/cuid2"; +import * as validators from "../routes/validation"; +import {handlePost as handleContentPost} from "../routes/content"; +import {getDb as startDb} from "../db/config"; +import * as schema from "../db/schema/schema"; +import {eq, and, inArray} from "drizzle-orm"; +import {handlePost as handleRenderingPost} from "../routes/rendering"; +import {insertContent} from "../db/schema/validations"; + +const db = startDb(); + +const numberInString = z.string().transform((val, ctx) => { + const parsed = parseInt(val); + if (isNaN(parsed)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Not a number", + }); + + // This is a special symbol you can use to + // return early from the transform function. + // It has type `never` so it does not affect the + // inferred return type. + return z.NEVER; + } + return parsed; +}); + +const audioMessageFileSchema = z.object({ + size: z.number(), + url: z.string(), + fileType: z.string().toLowerCase(), + hash: z.string(), + isWholeBook: z.boolean(), + isWholeProject: z.boolean(), + bookName: z.string(), + bookSlug: z.string(), + chapter: numberInString, +}); +const audioMessageSchema = z.object({ + languageIetf: z.string(), + name: z.string().toLowerCase(), + type: z.string(), + domain: z.string(), + resourceType: z.string(), + createdOn: z.string().optional(), //todo check + modifiedOn: z.string().optional(), //todo check + namespace: z.string().toLowerCase(), + files: z.array(audioMessageFileSchema), +}); + +export async function audioRenderedContentListener( + message: unknown, + context: InvocationContext +) { + console.log( + `Processing audio message. Invocation id is ${context.invocationId}` + ); + let contentCuid: string | null = null; //we need the guid of a content row to insert or upsert on the renderings Table. + + try { + const parsed = audioMessageSchema.passthrough().parse(message); + const {exists, id: currentExistingId} = await checkContentExists({ + name: parsed.name, + namespace: parsed.namespace, + db, + }); + if (currentExistingId) { + contentCuid = currentExistingId; + } + + await db.transaction(async (tx) => { + // prepare payload. Creating content also in transaction in case in fails to not orphan that content row + // If this unique name/namespace for a piece of content does not exist, then creat it in the db: + if (!exists) { + parsed.createdOn = parsed.createdOn || new Date().toISOString(); + parsed.modifiedOn = parsed.modifiedOn || new Date().toISOString(); + contentCuid = await createContentRow({ + context, + row: parsed, + }); + } + if (!contentCuid) { + throw new Error( + `Failed to find contentCuid for ${parsed.name}-${parsed.namespace}` + ); + } + + // messages for audio can't always fit into a single repo + all it's files since there are 3k plus files for each Bible due to mp3, cue, wav, etc for each chapter.So, we'll need to upsert on the url for each row in rendered_content table. + // But what about metadata row? But if I just insert the metadata row, there could be duplicates. Probably just best to query all rendered_content rows for a given contentId. Merge in any existing IDs from db, and then ovveride the rest of the properties with what's given. + const renderedContentRowsAlreadyInDb = await db + .select({ + renderedRowId: schema.rendering.id, + url: schema.rendering.url, + metadataId: schema.scripturalRenderingMetadata.id, + createdAt: schema.rendering.createdAt, + }) + .from(schema.rendering) + .leftJoin( + // metadata and rendered_row are 1-1; + schema.scripturalRenderingMetadata, + eq( + schema.rendering.id, + schema.scripturalRenderingMetadata.renderingId + ) + ) + .where( + and( + eq(schema.rendering.contentId, contentCuid), + // we only have a slice of the files from bus meesage, so we should filter this result based on the urls from parsed queue message: What this query returns will be mapped against queue messages for ids and then updated. If there is something from queue that is not in db, it'll be inserted. + inArray( + schema.rendering.url, + parsed.files.map((f) => f.url) + ) + ) + ); + // queue messages are ones to process, but we'll grab id's from db on existing for upserts; + // Create a set of rendered_row + meta for each retrieval. + // renderedRowsWithMetaSet + type returnedRowType = (typeof renderedContentRowsAlreadyInDb)[number]; + let renderedRowsLookup: Record = {}; + renderedContentRowsAlreadyInDb.forEach((row) => { + // @ts-ignore. Some types are wrong somewhere, cause this is become a day when queried back out. + if (row.createdAt instanceof Date) { + row.createdAt = row.createdAt.toISOString(); + } + renderedRowsLookup[row.url] = row; + }); + + // Prepare the rows to insert and upsert renderedContent and meta; + const dbPayload: z.infer = + parsed.files.map((file) => { + // used to tie together metadata to a rendering and maintain a key constraint. + const tempId = createId(); + let payload: z.infer = { + tempId: tempId, + namespace: parsed.namespace, + contentId: contentCuid!, + fileType: file.fileType, + url: file.url, + fileSizeBytes: file.size, + hash: file.hash, + scripturalMeta: { + isWholeBook: file.isWholeBook, + isWholeProject: file.isWholeProject, + bookName: file.bookName, + bookSlug: file.bookSlug, + chapter: file.chapter, + tempId: tempId, + }, + }; + if (renderedRowsLookup[file.url]) { + // Add the ids if we have them, for upserts, otherwise leave blank to auto create + payload.createdAt = + renderedRowsLookup[file.url].createdAt || + new Date().toISOString(); + payload.id = renderedRowsLookup[file.url].renderedRowId; + //@ts-ignore. I gave the type above for autocomplate and warnings, but meta is there above. + payload.scripturalMeta.id = renderedRowsLookup[file.url].metadataId; + } + return payload; + }); + const postResult = await handleRenderingPost(dbPayload); + if (postResult.status != 200) { + tx.rollback(); + if (postResult.jsonBody) { + if (postResult.jsonBody.additionalErrors) { + const errMessage = JSON.stringify( + `ADDITIONAL ERRORS: \n ${postResult.jsonBody.additionalErrors}\n\n + LAST ERR: + ${postResult.jsonBody.message}` + ); + throw new Error(errMessage); + } else { + throw new Error( + postResult.jsonBody.message || "failed to post for some reason" + ); + } + } + } + }); + } catch (error) { + // @ts-ignore + let sessionId = + typeof message == "object" && !!message && "session_id" in message + ? message.session_id + : "unknown"; + context.error( + `Error processing ${context.invocationId}. Session_id was ${sessionId}` + ); + context.error(error); + if (error instanceof z.ZodError) { + error.issues.forEach((issue) => { + context.error(JSON.stringify(issue)); + }); + } + } +} + +type createContentRowArgs = { + context: InvocationContext; + row: z.infer; +}; +async function createContentRow({context, row}: createContentRowArgs) { + context.log( + `${row.namespace}-${row.name} is not already in api. Creating new row in table` + ); + const cuid = createId(); + const newContentPayload: insertContent = { + id: cuid, + name: row.name, + namespace: row.namespace, + type: "audio", + // @ts-ignore. All are scritpure hardcoded for now, gonna leave the hard code on the message sender side + domain: row.domain, + languageId: row.languageIetf, + resourceType: row.resourceType, + createdOn: row.createdOn, + modifiedOn: row.modifiedOn, + } as const; + const newContentRow: z.infer = [ + newContentPayload, + ]; + const newRowRes = await handleContentPost(newContentRow); + if (newRowRes.status !== 200) { + // Not catching here. Failing to have an actual content row when we needed to create one is reason for top level throw to catch this. + throw new Error( + `Failed to create new content row for ${row.namespace}-${row.name} ` + ); + } else { + return newContentPayload.id; + } +} + +console.log("booting up the listener for audio bible messages"); +app.serviceBusTopic("waAudioRenderings", { + connection: "BUS_CONN", + topicName: "audiobiel", + subscriptionName: "languageapi", + handler: audioRenderedContentListener, + isSessionsEnabled: true, +}); + +// 1 -> content doesn't exist yet? Lemme make it +// 2 -> content doesn't exist yet? Lemme make it + +// 1 -> make content +// 2 -> make content diff --git a/controller/src/functions/localization.ts b/controller/src/functions/localization.ts index 1f00b9e..0786329 100644 --- a/controller/src/functions/localization.ts +++ b/controller/src/functions/localization.ts @@ -1,6 +1,5 @@ import {app, InvocationContext, Timer} from "@azure/functions"; import {getDb as startDb} from "../db/config"; -import {localizations} from "../localizations"; import type {insertLocalizationType} from "../db/schema/validations"; import {insertSchemas} from "../db/schema/validations"; import {polymorphicInsert} from "../db/handlers"; @@ -8,6 +7,7 @@ import {onConflictSetAllFieldsToSqlExcluded} from "../utils"; import * as dbSchema from "../db/schema/schema"; import {eq, sql, and, ilike, isNotNull} from "drizzle-orm"; import {z} from "zod"; +import {readdir} from "fs/promises"; const db = startDb(); const table = insertSchemas.localization.table; @@ -28,6 +28,7 @@ export async function populateLocalization( ? `inserted ${bookNamesResult.length} rows of book names` : bookNamesResult.message ); + const resourceTypesResult = await populationResourceTypes(); context.log( Array.isArray(resourceTypesResult) @@ -36,7 +37,26 @@ export async function populateLocalization( ); } +async function getLocalizations() { + const cwd = process.cwd(); + const dirPath = `${cwd}/src/localizations`; + const files = await readdir(dirPath); + const localizations = await Promise.all( + files.map(async (file) => { + // const filePath = `${dirPath}/${file}`; + const importString = `../localizations/${file.replace(".ts", "")}`; + const module = await import(importString); + return module.default as { + dict: Record; + ietf: string; + }; + }) + ); + return localizations; +} + async function populationResourceTypes() { + const localizations = await getLocalizations(); const category = "resource_type"; const payload = localizations.reduce( (acc: insertLocalizationType[], curr) => { diff --git a/controller/src/functions/renderings-bus.ts b/controller/src/functions/renderings-bus.ts index 8997f9e..cc94b3a 100644 --- a/controller/src/functions/renderings-bus.ts +++ b/controller/src/functions/renderings-bus.ts @@ -8,15 +8,18 @@ import { import {handlePost as handleContentPost} from "../routes/content"; import {handlePost as handleGitPost} from "../routes/git"; import * as validators from "../routes/validation"; -import * as schema from "../db/schema/schema"; -import {and, eq} from "drizzle-orm"; +import {checkContentExists} from "../utils"; import {createId} from "@paralleldrive/cuid2"; import {determineResourceType} from "../utils"; const db = startDb(); - const renderedFileSchema = z.object({ - Path: z.string(), + Path: z.string().transform((val, ctx) => { + if (!val.startsWith("/")) { + val = `/${val}`; + } + return val; + }), Size: z.number(), FileType: z.string(), Hash: z.string(), @@ -39,6 +42,12 @@ const renderingsSchema = z.object({ RenderedAt: z.string(), RepoId: z.number(), RenderedFiles: z.array(renderedFileSchema), + FileBasePath: z.string().transform((val, ctx) => { + if (val.endsWith("/")) { + val.slice(0, -1); + } + return val; + }), Titles: titlesSchema, }); @@ -71,6 +80,7 @@ export async function wacsSbRenderingsApi( const {exists, id: currentExistingId} = await checkContentExists({ name: joinedName, namespace, + db, }); if (currentExistingId) contentCuid = currentExistingId; await db.transaction(async (tx) => { @@ -120,7 +130,7 @@ export async function wacsSbRenderingsApi( throw new Error(`Failed to find contentCuid for ${joinedName}`); } - // we got a repoRendered from gitea, so create a git row while we are it. + // we got a repoRendered from gitea, so create a git row while we are it. go ahead and upsert in case the repoName or username or something changed const newGitRow: z.infer = [ { contentId: contentCuid, @@ -154,12 +164,16 @@ export async function wacsSbRenderingsApi( namespace, contentId: contentCuid!, fileType: payload.FileType, - url: payload.Path, + // zod handles the / separatore. Base Path should end with + url: `${parsed.FileBasePath}${payload.Path}`, fileSizeBytes: payload.Size || 0, hash: payload.Hash, }; const domain = determineResourceType(parsed.ResourceType); - if (["scripture", "gloss", "parascriptural"].includes(domain || "")) { + if ( + !!domain && + ["scripture", "gloss", "parascriptural"].includes(domain) + ) { const bookName = parsed.Titles[payload.Book || ""]; const isWholeBook = !payload.Chapter && !!payload.Book; let isWholeProject = @@ -218,8 +232,15 @@ export async function wacsSbRenderingsApi( } }); } catch (error) { - context.error(`Error processing ${JSON.stringify(message)}`); - context.error(error); + const repo = + typeof message == "object" && !!message && "Repo" in message + ? message.Repo + : "unknown repo"; + const user = + typeof message == "object" && !!message && "User" in message + ? message.User + : "unknown user"; + context.error(`Error processing ${user}/${repo}`); if (error instanceof z.ZodError) { error.issues.forEach((issue) => { context.error(JSON.stringify(issue)); @@ -228,31 +249,13 @@ export async function wacsSbRenderingsApi( } } -export async function checkContentExists({ - name, - namespace, -}: { - name: string; - namespace: string; -}) { - const doesExist = await db - .select({id: schema.content.id}) - .from(schema.content) - .where( - and( - eq(schema.content.namespace, namespace), - eq(schema.content.name, name) - ) - ); - - let dbId = doesExist[0]?.id ?? null; - - return {exists: doesExist.length > 0, id: dbId}; -} console.log("booting up the renderings bus listener"); app.serviceBusTopic("waLangApiRenderings", { connection: "BUS_CONN", topicName: "reporendered", - subscriptionName: "reporendered-languageapi", + subscriptionName: + process.env.NODE_ENV?.toUpperCase() == "DEV" + ? "will-local" + : "reporendered-languageapi", handler: wacsSbRenderingsApi, }); diff --git a/controller/src/localizations/en.ts b/controller/src/localizations/en.ts index bd39224..ba783c6 100644 --- a/controller/src/localizations/en.ts +++ b/controller/src/localizations/en.ts @@ -1,6 +1,7 @@ const en = { tw: "Translations Words", tn: "Translation Notes", + obs: "Open Bible Stories", }; export type keysType = keyof typeof en; export default {dict: en, ietf: "en"}; diff --git a/controller/src/localizations/es.ts b/controller/src/localizations/es.ts deleted file mode 100644 index a37205a..0000000 --- a/controller/src/localizations/es.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {keysType} from "./en"; -export const es: Record = { - tn: "Notas de traducción", - tw: "Palabras de traducción", -}; - -export default {dict: es, ietf: "es-419"}; diff --git a/controller/src/localizations/index.ts b/controller/src/localizations/index.ts deleted file mode 100644 index 75374eb..0000000 --- a/controller/src/localizations/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import en from "./en"; -import es from "./es"; - -export const localizations = [en, es]; - -// Obj diff --git a/controller/src/routes/content.ts b/controller/src/routes/content.ts index 463d013..c1b2eef 100644 --- a/controller/src/routes/content.ts +++ b/controller/src/routes/content.ts @@ -99,6 +99,7 @@ export async function handlePost(payload: unknown): Promise { const payloadsWithGuids = payloadParsed.map((payload) => { return { ...payload, + // some routes provide their own unique cuids id: payload.id ? payload.id : createId(), }; }); diff --git a/controller/src/routes/validation/index.ts b/controller/src/routes/validation/index.ts index 75a92c0..61a00d0 100644 --- a/controller/src/routes/validation/index.ts +++ b/controller/src/routes/validation/index.ts @@ -79,7 +79,7 @@ export const contentDelete = z.object({ /* //@=========== Renderings =========== */ export const contentRenderingWithMeta = dbValidators.insertRenderingSchema.extend({ - tempId: z.string(), + tempId: z.string(), //Tghis is used not in db, but for doing some connecting meta and rendering during batched inserts. fileType: z.string().trim().toLowerCase(), namespace: z.string().trim().toLowerCase(), scripturalMeta: dbValidators.insertScripturalRenderingMetadataSchema diff --git a/controller/src/utils.ts b/controller/src/utils.ts index e8b6cc5..f4d2a5d 100644 --- a/controller/src/utils.ts +++ b/controller/src/utils.ts @@ -1,8 +1,10 @@ -import {DrizzleError, SQL, sql} from "drizzle-orm"; +import {DrizzleError, SQL, and, eq, sql} from "drizzle-orm"; import {PgTableWithColumns, TableConfig} from "drizzle-orm/pg-core"; import {PostgresError} from "postgres"; import {handlerReturnError} from "./customTypes/types"; import {ZodError} from "zod"; +import * as schema from "./db/schema/schema"; +import {PostgresJsDatabase} from "drizzle-orm/postgres-js"; export const BibleBookCategories = { OT: [ @@ -233,6 +235,28 @@ export function statusCodeFromErrType(err: unknown) { return 400; } +export async function checkContentExists({ + name, + namespace, + db, +}: { + name: string; + namespace: string; + db: PostgresJsDatabase; +}) { + const doesExist = await db + .select({id: schema.content.id}) + .from(schema.content) + .where( + and( + eq(schema.content.namespace, namespace), + eq(schema.content.name, name) + ) + ); + + let dbId = doesExist[0]?.id ?? null; + return {exists: doesExist.length > 0, id: dbId}; +} export function determineResourceType(slug: string) { // "scripture" | "gloss" | "parascriptural" | "peripheral" | null | undefined const upperSlug = slug.toUpperCase(); diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 0000000..0df4260 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,3 @@ +files: + - source: /controller/src/localizations/en.ts + translation: /controller/src/localizations/%two_letters_code%.ts diff --git a/docker-compose.yml b/docker-compose.yml index 6b802b2..8173c15 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,4 @@ #TODO: There's a lot of placeholders in here -version: "3.8" services: # controller: diff --git a/makefile b/makefile index 6e0b6b7..29e251f 100644 --- a/makefile +++ b/makefile @@ -4,10 +4,10 @@ console: --admin-secret $(op read "op://AppDev Scripture Accessibility/languageapi-hasura-dev-container-secrets/hasura-graphql-admin-secret") \ --endpoint $(op read "op://AppDev Scripture Accessibility/languageapi-hasura-dev-container-secrets/url") -# -n drizzle schema is for to get the same migrations that have been applied to the dev database applied. +# -n drizzle schema is for to get the same migrations that have been applied to the dev database applied. This presumes a workflow of dumping dev data incl. it's migrations, and then trying out new stuff from there. When the docker compose postgres volume spins ups, its uses psql and mounts in this dump. .PHONY: datadump datadump: - pg_dump $(op read "op://AppDev Scripture Accessibility/languageapi-dev/connection string") -n public -n drizzle > data_dump.sql + pg_dump $(op read "op://AppDev Scripture Accessibility/languageapi-dev/connection string") -n public -n drizzle > ./controller/data_dump.sql # Note, this will conflict if you're running postgres on own machine on default port too. .PHONY: localdataingest