diff --git a/.project-management/current-prd/prd-background/design-mock.html b/.project-management/current-prd/prd-background/design-mock.html deleted file mode 100644 index 8b09461ba..000000000 --- a/.project-management/current-prd/prd-background/design-mock.html +++ /dev/null @@ -1 +0,0 @@ -*PLACEHOLDER FILE* diff --git a/.project-management/current-prd/prd-background/feature-specification.md b/.project-management/current-prd/prd-background/feature-specification.md index 9d841308b..8a7511e02 100644 --- a/.project-management/current-prd/prd-background/feature-specification.md +++ b/.project-management/current-prd/prd-background/feature-specification.md @@ -1 +1 @@ -_PLACEHOLDER FILE_ +i file in src/features/db/class sono deprecati. tutti i punti che li usano dovrebbero invece usare knex (tramite tryber in src/features/database.ts). diff --git a/.project-management/current-prd/prd-db-class-migration.md b/.project-management/current-prd/prd-db-class-migration.md new file mode 100644 index 000000000..0408de0b3 --- /dev/null +++ b/.project-management/current-prd/prd-db-class-migration.md @@ -0,0 +1,51 @@ +## 1. Introduction/Overview + +The project currently has several deprecated classes in `src/features/db/class` that are still being used by parts of the codebase. The goal is to replace these classes with the Knex-based implementation provided by `tryber` in `src/features/database.ts`. The replacement should be one-to-one with no API changes and no behavioral regressions. + +## 2. Goals + +- Remove all dependencies on code in `src/features/db/class`. +- Migrate all database queries to use the `tryber` Knex instance from `src/features/database.ts`. +- Ensure the test suite passes after migration. + +## 3. User Stories + +1. **As a developer**, I want the database access layer to use a single Knex-based implementation so that the codebase is easier to maintain. +2. **As a developer**, I need the migration to avoid any API or behavior changes so that existing integrations continue to work. + +## 4. Functional Requirements + +1. Identify all modules and endpoints that import or instantiate classes from `src/features/db/class`. +2. Refactor these modules to use Knex queries via `src/features/database.ts` instead. +3. Ensure that all database interactions preserve existing data structures and behavior. +4. Update or create tests where needed so the entire test suite passes. + +## 5. Non-Goals (Out of Scope) + +- Changing API endpoints or response formats. +- Adding new features unrelated to database access. +- Modifying database schemas or introducing migrations. + +## 6. Design Considerations + +- Keep code changes localized to maintain readability. +- Favor small, incremental refactors with comprehensive tests. + +## 7. Technical Considerations + +- `@appquality/tryber-database` provides the Knex-based connection and should remain up to date. +- Existing environment configuration in `src/features/database.ts` must be reused for all queries. + +## 8. Success Metrics + +- All previous unit and integration tests pass without modification (unless tests rely on deprecated classes). +- No regressions reported after deployment. + +## 9. Open Questions + +- Are there modules outside of `src/features` that depend on the deprecated classes? +- Do we need additional logging to monitor for unexpected behavior during migration? + +## 10. Referenced PRD-background files + +- `.project-management/current-prd/prd-background/feature-specification.md` – states the goal of replacing deprecated classes in `src/features/db/class` with the `tryber` Knex implementation. diff --git a/.project-management/current-prd/prd-feature2.md b/.project-management/current-prd/prd-feature2.md deleted file mode 100644 index 9d841308b..000000000 --- a/.project-management/current-prd/prd-feature2.md +++ /dev/null @@ -1 +0,0 @@ -_PLACEHOLDER FILE_ diff --git a/.project-management/current-prd/tasks-prd-db-class-migration.md b/.project-management/current-prd/tasks-prd-db-class-migration.md new file mode 100644 index 000000000..b87190678 --- /dev/null +++ b/.project-management/current-prd/tasks-prd-db-class-migration.md @@ -0,0 +1,91 @@ +## Pre-Feature Development Project Tree + +``` +. +deployment +keys +src +src/__mocks__ +src/features +src/middleware +src/reference +src/routes +features +features/OpenapiError +features/__mocks__ +features/busboyMapper +features/checkUrl +features/class +features/db +features/debugMessage +features/deleteFromS3 +features/escapeCharacters +features/getMimetypeFromS3 +features/jotform +features/leaderboard +features/mail +features/paypal +features/routes +features/s3 +features/sentry +features/sqlite +features/tranferwise +features/upload +features/webhookTrigger +features/wp +``` + +## Relevant Files + +- `src/features/db/class/*` - Deprecated database classes to be replaced +- `src/features/database.ts` - Knex connection instance (`tryber`) +- Various modules under `src/` importing from `src/features/db/class` +- `.project-management/current-prd/tasks-prd-db-class-migration.md` - Task list for DB class migration + +### Proposed New Files + +- _None at this stage_ + +### Existing Files Modified + +- `src/routes/campaigns/forms/_get/index.ts` - migrated to use `tryber` instead of db class +- `src/routes/campaigns/campaignId/forms/_get/index.ts` - migrated to use `tryber` +- `src/routes/users/me/campaigns/campaignId/compatible_devices/_get/index.ts` - migrated to use `tryber` +- Modules in `src/features` and `src/routes` that rely on deprecated classes +- Test files associated with the above modules + +### Notes + +- Keep `@appquality/tryber-database` updated in `package.json` +- Ensure no API or behavior changes during migration + +## Tasks + +- [x] 1.0 Inventory current usage of classes from `src/features/db/class` +- [ ] 2.0 Refactor identified modules to use `tryber` queries via `src/features/database.ts` +- [x] 2.1 Refactor `src/routes/campaigns/forms/_get/index.ts` to use `tryber` +- [x] 2.2 Refactor `src/routes/campaigns/campaignId/forms/_get/index.ts` to use `tryber` +- [x] 2.3 Refactor `src/routes/users/me/campaigns/campaignId/compatible_devices/_get/index.ts` to use `tryber` +- [ ] 3.0 Update or create tests to cover the refactored code +- [x] 3.1 Update tests for `src/routes/campaigns/campaignId/forms/_get/index.ts` +- [ ] 4.0 Remove deprecated classes once all references are migrated +- [ ] 5.0 Run full test suite and verify no regressions + +### Task 1.0 Inventory Results + +- `src/routes/campaigns/forms/_get/index.ts` +- `src/routes/campaigns/campaignId/forms/_get/index.ts` +- `src/routes/campaigns/campaignId/candidates/_post/index.ts` +- `src/routes/users/me/campaigns/campaignId/compatible_devices/_get/index.ts` +- `src/routes/users/me/campaigns/campaignId/forms/_get/index.ts` +- `src/routes/users/me/campaigns/campaignId/forms/_post/index.ts` +- `src/routes/users/me/campaigns/campaignId/forms/QuestionFactory/index.ts` +- `src/routes/users/me/campaigns/campaignId/forms/QuestionFactory/Questions/index.ts` +- `src/routes/users/me/campaigns/campaignId/forms/QuestionFactory/Questions/AddressQuestion.ts` +- `src/routes/users/me/campaigns/campaignId/forms/QuestionFactory/Questions/CufMultiSelectQuestion.ts` +- `src/routes/users/me/campaigns/campaignId/forms/QuestionFactory/Questions/CufSelectableQuestion.ts` +- `src/routes/users/me/campaigns/campaignId/forms/QuestionFactory/Questions/CufTextQuestion.ts` +- `src/routes/users/me/campaigns/campaignId/forms/QuestionFactory/Questions/GenderQuestion.ts` +- `src/routes/users/me/campaigns/campaignId/forms/QuestionFactory/Questions/PhoneQuestion.ts` +- `src/routes/users/me/campaigns/campaignId/forms/QuestionFactory/Questions/SelectableQuestion.ts` +- `src/routes/users/me/campaigns/campaignId/forms/QuestionFactory/Questions/SimpleTextQuestion.ts` diff --git a/.project-management/current-prd/tasks-prd-feature2.md b/.project-management/current-prd/tasks-prd-feature2.md deleted file mode 100644 index 9d841308b..000000000 --- a/.project-management/current-prd/tasks-prd-feature2.md +++ /dev/null @@ -1 +0,0 @@ -_PLACEHOLDER FILE_ diff --git a/AGENTS.md b/AGENTS.md index 0da8b4f12..0d205fca4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ npx prettier --ignore-unknown --write ## Testing Instructions +Run the tests only when a ts, js or json file is changed. NEVER run if only md files are changed. Run tests with `yarn test`. Any tests that require network connectivity should either be ignored and not run, -or- have network test path that shunts to a success when network connectivity can't be demonstrated so failed tests in this scenario don't confuse the codex agent progress. ## CHANGELOG.md Instructions diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..8c5fb7254 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +2025-06-05 - add PRD for database class migration +2025-06-05 - add high-level tasks for db class migration PRD +2025-06-05 - refactor campaigns forms route to use tryber +2025-06-05 - migrated campaignId forms route to use tryber diff --git a/package.json b/package.json index 3ef409ddb..afd2caf0a 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "author": "", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.44.6", + "@appquality/tryber-database": "0.44.17", "@appquality/wp-auth": "^1.0.7", "@googlemaps/google-maps-services-js": "^3.3.7", "@sendgrid/mail": "^7.6.0", diff --git a/src/routes/campaigns/campaignId/forms/_get/index.ts b/src/routes/campaigns/campaignId/forms/_get/index.ts index 615a1f114..1b7ffee5e 100644 --- a/src/routes/campaigns/campaignId/forms/_get/index.ts +++ b/src/routes/campaigns/campaignId/forms/_get/index.ts @@ -1,43 +1,40 @@ /** OPENAPI-CLASS: get-campaigns-campaign-forms */ import UserRoute from "@src/features/routes/UserRoute"; -import PreselectionFormFields, { - PreselectionFormFieldsObject, -} from "@src/features/db/class/PreselectionFormFields"; -import PreselectionForm from "@src/features/db/class/PreselectionForms"; +import { tryber } from "@src/features/database"; import OpenapiError from "@src/features/OpenapiError"; export default class RouteItem extends UserRoute<{ response: StoplightOperations["get-campaigns-campaign-forms"]["responses"][200]["content"]["application/json"]; parameters: StoplightOperations["get-campaigns-campaign-forms"]["parameters"]["path"]; }> { - private db: { questions: PreselectionFormFields; form_id: PreselectionForm }; private campaign_id: number; private form_id: number = 0; - private questions: PreselectionFormFieldsObject[] | false = false; + private questions: + | { + id: number; + question: string; + short_name: string | null; + }[] + | false = false; constructor(config: RouteClassConfiguration) { super(config); const parameters = this.getParameters(); this.campaign_id = parseInt(parameters.campaign); - this.db = { - questions: new PreselectionFormFields(["id", "question", "short_name"]), - form_id: new PreselectionForm(["id"]), - }; } protected async init() { - const forms = await this.db.form_id.query({ - where: [{ campaign_id: this.campaign_id }], - }); - if (forms.length === 0) { - this.form_id = 0; - } else { - this.form_id = forms[0].id; - } + const form = await tryber.tables.WpAppqCampaignPreselectionForm.do() + .select("id") + .where("campaign_id", this.campaign_id) + .first(); - this.questions = await this.db.questions.query({ - where: [{ form_id: this.form_id }], - orderBy: [{ field: "priority" }], - }); + this.form_id = form ? form.id : 0; + + this.questions = + await tryber.tables.WpAppqCampaignPreselectionFormFields.do() + .select("id", "question", "short_name") + .where("form_id", this.form_id) + .orderBy("priority"); if (this.questions.length === 0) { this.questions = false; } diff --git a/src/routes/campaigns/forms/_get/index.spec.ts b/src/routes/campaigns/forms/_get/index.spec.ts index 156092351..7f9290a72 100644 --- a/src/routes/campaigns/forms/_get/index.spec.ts +++ b/src/routes/campaigns/forms/_get/index.spec.ts @@ -1,5 +1,5 @@ -import preselectionForm from "@src/__mocks__/mockedDb/preselectionForm"; import app from "@src/app"; +import preselectionForm from "@src/__mocks__/mockedDb/preselectionForm"; import request from "supertest"; describe("GET /campaigns/forms ", () => { beforeAll(async () => { @@ -244,6 +244,7 @@ describe("GET /campaigns/forms ", () => { "authorization", `Bearer tester capability ["manage_preselection_forms"]` ); + console.log(response.body); expect(response.body.results[0]).toMatchObject({ id: 3, name: "Form Name3 with campaign Id", diff --git a/src/routes/campaigns/forms/_get/index.ts b/src/routes/campaigns/forms/_get/index.ts index 256d434bc..519327fb8 100644 --- a/src/routes/campaigns/forms/_get/index.ts +++ b/src/routes/campaigns/forms/_get/index.ts @@ -1,13 +1,12 @@ /** OPENAPI-CLASS: get-campaigns-forms */ +import { tryber } from "@src/features/database"; import UserRoute from "@src/features/routes/UserRoute"; -import PreselectionForms from "@src/features/db/class/PreselectionForms"; export default class RouteItem extends UserRoute<{ response: StoplightOperations["get-campaigns-forms"]["responses"][200]["content"]["application/json"]; query: StoplightOperations["get-campaigns-forms"]["parameters"]["query"]; }> { - private db: { forms: PreselectionForms }; private limit: number | undefined; private start: number; private searchBy: ("name" | "campaign_id")[] | undefined; @@ -15,7 +14,6 @@ export default class RouteItem extends UserRoute<{ constructor(config: RouteClassConfiguration) { super(config); - this.db = { forms: new PreselectionForms(["id", "name", "campaign_id"]) }; const query = this.getQuery(); this.limit = parseInt(query.limit as unknown as string) || undefined; this.start = parseInt((query.start as unknown as string) || "0"); @@ -51,37 +49,47 @@ export default class RouteItem extends UserRoute<{ } private async getForms() { - const results = await this.db.forms.query({ - limit: this.limit, - where: this.getWhere(), - orderBy: [{ field: "id", order: "DESC" }], - offset: this.start, - }); - return results.map((form) => { - return { - id: form.id, - name: form.name, - campaign: form.campaign_id !== null ? form.campaign_id : undefined, - }; - }); + const query = tryber.tables.WpAppqCampaignPreselectionForm.do() + .select("id", "name", "campaign_id") + .orderBy("id", "DESC") + .offset(this.start); + + if (this.limit) query.limit(this.limit); + this.applySearch(query); + + const results = await query; + return results.map((form) => ({ + id: form.id, + name: form.name, + campaign: form.campaign_id !== null ? form.campaign_id : undefined, + })); } private async getTotal() { - const results = await this.db.forms.query({ where: this.getWhere() }); - return this.limit ? results.length : undefined; + if (this.limit === undefined) return undefined; + const query = tryber.tables.WpAppqCampaignPreselectionForm.do().count({ + count: "id", + }); + this.applySearch(query); + const result = await query; + const total = result[0].count as number | string; + return typeof total === "number" ? total : parseInt(total); } - private getWhere() { - if (!this.searchBy || !this.search) return undefined; - const searchFields = this.searchBy; + private applySearch< + T extends ReturnType< + ReturnType< + typeof tryber.tables.WpAppqCampaignPreselectionForm.do + >["count"] + > + >(query: T) { + if (!this.searchBy || !this.search) return; const search = this.search; - const orQuery: PreselectionForms["where"][number] = searchFields.map( - (searchField) => { - return { [searchField]: "%" + search + "%", isLike: true }; - } - ); - - return [orQuery]; + query.where((builder) => { + this.searchBy!.forEach((field) => { + builder.orWhereLike(field, `%${search}%`); + }); + }); } private isSearchByAcceptable(searchField: string) { return ["name", "campaign_id"].includes(searchField); diff --git a/src/routes/users/me/campaigns/campaignId/compatible_devices/_get/index.ts b/src/routes/users/me/campaigns/campaignId/compatible_devices/_get/index.ts index 3f39c2a85..aaebdb0af 100644 --- a/src/routes/users/me/campaigns/campaignId/compatible_devices/_get/index.ts +++ b/src/routes/users/me/campaigns/campaignId/compatible_devices/_get/index.ts @@ -1,26 +1,40 @@ /** OPENAPI-CLASS: get-users-me-campaigns-campaignId-compatible-devices */ import UserRoute from "@src/features/routes/UserRoute"; -import Campaigns, { CampaignObject } from "@src/features/db/class/Campaigns"; -import TesterDevices, { - TesterDeviceObject, -} from "@src/features/db/class/TesterDevices"; +import { tryber } from "@src/features/database"; +import { UserTargetChecker } from "@src/routes/users/me/campaigns/_get/UserTargetChecker"; class RouteItem extends UserRoute<{ parameters: StoplightOperations["get-users-me-campaigns-campaignId-compatible-devices"]["parameters"]["path"]; response: StoplightOperations["get-users-me-campaigns-campaignId-compatible-devices"]["responses"]["200"]["content"]["application/json"]; }> { private campaign_id: number; - private db: { campaigns: Campaigns; testerDevices: TesterDevices }; - private campaign: CampaignObject | undefined; - private devices: TesterDeviceObject[] | undefined; + private campaign: + | { + id: number; + page_preview_id: number; + is_public: number; + start_date: string; + os: string | null; + } + | undefined; + private devices: + | { + id: number; + form_factor: string; + manufacturer: string | null; + model: string | null; + pc_type: string | null; + source_id: number | null; + os_version_id: number; + os: string; + display_name: string; + version_number: string; + }[] + | undefined; constructor(configuration: RouteClassConfiguration) { super(configuration); - this.db = { - campaigns: new Campaigns(), - testerDevices: new TesterDevices(), - }; this.campaign_id = parseInt(this.getParameters().campaign); } @@ -65,46 +79,130 @@ class RouteItem extends UserRoute<{ } private async candidatureIsAvailable(): Promise { - return (await this.getCampaign()).isApplicationAvailable(); + const campaign = await this.getCampaign(); + const today = new Date().toISOString().split(".")[0].replace("T", " "); + return new Date(campaign.start_date) >= new Date(today); } private async userCanAccessToForm() { - return (await this.getCampaign()).testerHasAccess(this.getTesterId()); + const campaign = await this.getCampaign(); + if (campaign.is_public === 1) return true; + if (campaign.is_public === 3) { + const access = await tryber.tables.WpAppqLcAccess.do() + .select("id") + .where("tester_id", this.getTesterId()) + .where("view_id", campaign.page_preview_id) + .first(); + return !!access; + } + if (campaign.is_public === 4) { + const checker = new UserTargetChecker({ testerId: this.getTesterId() }); + await checker.init(); + return checker.inTarget(await this.getTargetRules()); + } + return false; } private async getCampaign() { if (!this.campaign) { - this.campaign = await this.db.campaigns.get(this.campaign_id); + const campaign = await tryber.tables.WpAppqEvdCampaign.do() + .select("id", "page_preview_id", "is_public", "start_date", "os") + .where("id", this.campaign_id) + .first(); + if (!campaign) throw new Error("Campaign not found"); + this.campaign = campaign; } return this.campaign; } private async getCompatibleDevices() { if (!this.devices) { - const where: Parameters< - typeof this.db.testerDevices.query - >[number]["where"] = [{ id_profile: this.getTesterId() }, { enabled: 1 }]; - const campaign = await this.getCampaign(); - if (campaign.acceptedOs.length > 0) { - where.push({ - platform_id: campaign.acceptedOs, - }); + const acceptedOs = campaign.os + ? campaign.os.split(",").map((o) => parseInt(o)) + : []; + + const query = tryber.tables.WpCrowdAppqDevice.do() + .select( + tryber.ref("id").withSchema("wp_crowd_appq_device"), + tryber.ref("form_factor").withSchema("wp_crowd_appq_device"), + "manufacturer", + "model", + "pc_type", + "source_id", + "os_version_id", + tryber.ref("wp_appq_evd_platform.name").as("os"), + tryber.ref("display_name").withSchema("wp_appq_os"), + tryber.ref("version_number").withSchema("wp_appq_os") + ) + .join( + "wp_appq_evd_platform", + "wp_crowd_appq_device.platform_id", + "wp_appq_evd_platform.id" + ) + .join( + "wp_appq_os", + "wp_crowd_appq_device.os_version_id", + "wp_appq_os.id" + ) + .where("id_profile", this.getTesterId()) + .where("enabled", 1); + + if (acceptedOs.length > 0) { + query.whereIn("wp_crowd_appq_device.platform_id", acceptedOs); } - this.devices = await this.db.testerDevices.query({ - where, - }); + + this.devices = await query; } return this.devices; } private async getEnhancedDevices() { const devices = await this.getCompatibleDevices(); - const ehancedDevices = []; - for (const device of devices) { - ehancedDevices.push(await device.getFull()); - } - return ehancedDevices; + return devices.map((device) => ({ + id: device.id, + type: device.form_factor, + device: + device.form_factor === "PC" && device.pc_type + ? { pc_type: device.pc_type } + : { + id: device.source_id || 0, + manufacturer: device.manufacturer || "", + model: device.model || "", + }, + operating_system: { + id: device.os_version_id, + platform: device.os, + version: `${device.display_name} (${device.version_number})`, + }, + })); + } + + private async getTargetRules() { + const allowedLanguages = + await tryber.tables.CampaignDossierDataLanguages.do() + .select("language_name") + .join( + "campaign_dossier_data", + "campaign_dossier_data.id", + "campaign_dossier_data_languages.campaign_dossier_data_id" + ) + .where("campaign_dossier_data.campaign_id", this.campaign_id); + + const allowedCountries = + await tryber.tables.CampaignDossierDataCountries.do() + .select("country_code") + .join( + "campaign_dossier_data", + "campaign_dossier_data.id", + "campaign_dossier_data_countries.campaign_dossier_data_id" + ) + .where("campaign_dossier_data.campaign_id", this.campaign_id); + + return { + languages: allowedLanguages.map((l) => l.language_name), + countries: allowedCountries.map((c) => c.country_code), + }; } } diff --git a/yarn.lock b/yarn.lock index b8d0e6130..2490bdc54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -37,14 +37,14 @@ __metadata: languageName: node linkType: hard -"@appquality/tryber-database@npm:^0.44.6": - version: 0.44.6 - resolution: "@appquality/tryber-database@npm:0.44.6" +"@appquality/tryber-database@npm:0.44.17": + version: 0.44.17 + resolution: "@appquality/tryber-database@npm:0.44.17" dependencies: better-sqlite3: "npm:^8.1.0" knex: "npm:^2.5.1" mysql: "npm:^2.18.1" - checksum: 10c0/c3161a23ccac091bdb3de3c4c3ee9610b70f82241945271b4d1108d4ec29d2f9ca7057abae4bda6d5268f051b66093884e14092b0e2b67e45d2b85e3301b4046 + checksum: 10c0/1950d9dc25e5f548b5824c22fe4d77016af9bcc9239e39288b0145d0419a66fb10a9ce28a1c814127e57a13a5bcf4e7c032f84d4e76e6d86895ced5ff87c759c languageName: node linkType: hard @@ -7362,7 +7362,7 @@ __metadata: resolution: "new-api@workspace:." dependencies: "@appquality/jest-extract": "npm:^1.3.0" - "@appquality/tryber-database": "npm:^0.44.6" + "@appquality/tryber-database": "npm:0.44.17" "@appquality/wp-auth": "npm:^1.0.7" "@googlemaps/google-maps-services-js": "npm:^3.3.7" "@sendgrid/mail": "npm:^7.6.0"