From 57e9d2836ca2a095870d05722bd388403c6d03d6 Mon Sep 17 00:00:00 2001 From: Conrad Bekondo Date: Thu, 16 Jan 2025 10:38:05 +0100 Subject: [PATCH 1/4] chore: update campaings template --- .../pages/campaigns/campaigns.component.html | 41 +++------- .../pages/campaigns/campaigns.component.ts | 74 ++++++------------- src/index.html | 2 +- 3 files changed, 36 insertions(+), 81 deletions(-) diff --git a/src/app/pages/campaigns/campaigns.component.html b/src/app/pages/campaigns/campaigns.component.html index 989171f..935783c 100644 --- a/src/app/pages/campaigns/campaigns.component.html +++ b/src/app/pages/campaigns/campaigns.component.html @@ -3,8 +3,7 @@

Campaigns

@@ -30,31 +29,21 @@

Campaigns

-

{{ campaign.title }}

+ {{ campaign.title }} + + + 1 category + {{ campaign.categories.length }} categories + 99+ categories - {{ campaign.categories.length }} categories {{ campaign.updatedAt | date }} -
- - -
- @if (selectedCampaign()) { -
- - -

Publications

-
- -
-
- }
@@ -62,15 +51,7 @@

Publications

New Campaign

- - - - -

Publish Campaign

-
- +
diff --git a/src/app/pages/campaigns/campaigns.component.ts b/src/app/pages/campaigns/campaigns.component.ts index b39dc16..1f8a321 100644 --- a/src/app/pages/campaigns/campaigns.component.ts +++ b/src/app/pages/campaigns/campaigns.component.ts @@ -1,35 +1,29 @@ -import { Component, computed, effect, inject, linkedSignal, model, ResourceRef, signal } from '@angular/core'; -import { TableModule } from 'primeng/table'; -import { Button } from 'primeng/button'; -import { InputText } from 'primeng/inputtext'; -import { IconField } from 'primeng/iconfield'; -import { InputIcon } from 'primeng/inputicon'; -import { Drawer } from 'primeng/drawer'; -import { ReactiveFormsModule } from '@angular/forms'; -import { DatePipe } from '@angular/common'; -import { MenuItem, MessageService, ToastMessageOptions } from 'primeng/api'; +import { DatePipe, NgPlural, NgPluralCase } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { Component, effect, inject, model, ResourceRef, signal } from '@angular/core'; import { rxResource -} from '@angular/core/rxjs-interop'; -import { - CountryData -} from '@lib/models/country-data'; -import { Category } from '@lib/models/category'; -import { HttpClient } from '@angular/common/http'; -import { Campaign, LookupCampaignResponse } from '@lib/models/campaign'; -import { Panel } from 'primeng/panel'; -import { Menu } from 'primeng/menu'; -import { DataViewModule } from 'primeng/dataview'; +} from '@angular/core/rxjs-interop'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; import { CampaignFormComponent -} from '@app/components/campaign-form/campaign-form.component'; -import { - CampaignPublicationsComponent -} from '@app/components/campaign-publications/campaign-publications.component'; +} from '@app/components/campaign-form/campaign-form.component'; +import { LookupCampaignResponse } from '@lib/models/campaign'; +import { Category } from '@lib/models/category'; import { - PublicationFormComponent -} from '@app/components/publication-form/publication-form.component'; -import { Ripple } from 'primeng/ripple'; + CountryData +} from '@lib/models/country-data'; +import { MessageService, ToastMessageOptions } from 'primeng/api'; +import { Button } from 'primeng/button'; +import { DataViewModule } from 'primeng/dataview'; +import { Drawer } from 'primeng/drawer'; +import { IconField } from 'primeng/iconfield'; +import { InputIcon } from 'primeng/inputicon'; +import { InputText } from 'primeng/inputtext'; +import { Panel } from 'primeng/panel'; +import { Ripple } from 'primeng/ripple'; +import { TableModule } from 'primeng/table'; @Component({ selector: 'tm-campaigns', @@ -43,11 +37,11 @@ import { Ripple } ReactiveFormsModule, DatePipe, Panel, - Menu, DataViewModule, CampaignFormComponent, - CampaignPublicationsComponent, - PublicationFormComponent, + NgPlural, + NgPluralCase, + RouterLink, Ripple ], templateUrl: './campaigns.component.html', @@ -56,8 +50,6 @@ import { Ripple } export class CampaignsComponent { private messageService = inject(MessageService); private http = inject(HttpClient); - readonly selectedCampaign = model(); - readonly targetCampaignId = linkedSignal(() => this.selectedCampaign()?.id) showCampaignModal = model(false); showPublicationModal = model(false); currentPage = model(0); @@ -81,24 +73,6 @@ export class CampaignsComponent { }) }); - readonly campaignOptions = computed(() => { - return (this.campaigns.value()?.data ?? []).map((campaign: Campaign) => { - return [ - { - label: 'Publish', - icon: 'pi pi-megaphone', - command: () => { - this.targetCampaignId.set(campaign.id); - this.showPublicationModal.set(true); - } - }, - { label: 'Edit', icon: 'pi pi-pencil' }, - { separator: true }, - { label: 'Delete', icon: 'pi pi-trash' } - ] as MenuItem[]; - }) - }) - onCampaignFormSubmitted() { this.campaigns.reload(); this.showCampaignModal.set(false); diff --git a/src/index.html b/src/index.html index d279d13..2694711 100644 --- a/src/index.html +++ b/src/index.html @@ -1,5 +1,5 @@ - + TellThem From 86ff9b7cfcc2efb17911342aff13e684aa120113 Mon Sep 17 00:00:00 2001 From: Conrad Bekondo Date: Thu, 16 Jan 2025 10:42:09 +0100 Subject: [PATCH 2/4] chore: add campaign component --- src/app/app.routes.ts | 5 +++++ src/app/pages/campaign/campaign.component.html | 1 + src/app/pages/campaign/campaign.component.scss | 0 src/app/pages/campaign/campaign.component.ts | 11 +++++++++++ 4 files changed, 17 insertions(+) create mode 100644 src/app/pages/campaign/campaign.component.html create mode 100644 src/app/pages/campaign/campaign.component.scss create mode 100644 src/app/pages/campaign/campaign.component.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 28ee268..6708842 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -10,6 +10,11 @@ export const appRoutes: Routes = [ path: 'auth', loadChildren: () => import('./auth.routes').then(m => m.authRoutes) }, + { + path: 'campaigns/:id', + canActivate: [signedInGuard], + loadComponent: () => import('./pages/campaign/campaign.component').then(m => m.CampaignComponent) + }, { path: 'campaigns', canActivate: [signedInGuard], diff --git a/src/app/pages/campaign/campaign.component.html b/src/app/pages/campaign/campaign.component.html new file mode 100644 index 0000000..40d56c9 --- /dev/null +++ b/src/app/pages/campaign/campaign.component.html @@ -0,0 +1 @@ +

campaign works!

diff --git a/src/app/pages/campaign/campaign.component.scss b/src/app/pages/campaign/campaign.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/campaign/campaign.component.ts b/src/app/pages/campaign/campaign.component.ts new file mode 100644 index 0000000..b547a93 --- /dev/null +++ b/src/app/pages/campaign/campaign.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'tm-campaign', + imports: [], + templateUrl: './campaign.component.html', + styleUrl: './campaign.component.scss' +}) +export class CampaignComponent { + +} From 2cbe5d8330493b8fa89a4da32e9c24a32c1cccef Mon Sep 17 00:00:00 2001 From: Conrad Bekondo Date: Fri, 17 Jan 2025 01:42:11 +0100 Subject: [PATCH 3/4] chore: work in progress --- db/migrations/0026_mighty_moondragon.sql | 3 + db/migrations/0027_freezing_flatman.sql | 5 + db/migrations/meta/0026_snapshot.json | 1455 ++++++++++++++++ db/migrations/meta/0027_snapshot.json | 1460 +++++++++++++++++ db/migrations/meta/_journal.json | 14 + db/schema/campaigns.ts | 33 +- lib/models/campaign.ts | 12 +- server/functions/campaigns/index.mts | 20 +- server/handlers/campaign.mts | 57 +- src/app/app.component.html | 2 +- src/app/app.component.ts | 10 +- src/app/app.routes.ts | 10 +- .../campaign-analytics.component.html | 1 + .../campaign-analytics.component.scss | 0 .../campaign-analytics.component.ts | 11 + .../campaign-form.component.html | 313 +--- .../campaign-form/campaign-form.component.ts | 277 +--- .../campaign-publications.component.ts | 2 +- .../campaign-settings.component.html | 193 +++ .../campaign-settings.component.scss | 3 + .../campaign-settings.component.ts | 145 ++ .../pages/campaign/campaign.component.html | 30 +- .../pages/campaign/campaign.component.scss | 3 + src/app/pages/campaign/campaign.component.ts | 45 +- .../pages/campaigns/campaigns.component.html | 23 +- .../pages/campaigns/campaigns.component.ts | 17 +- 26 files changed, 3531 insertions(+), 613 deletions(-) create mode 100644 db/migrations/0026_mighty_moondragon.sql create mode 100644 db/migrations/0027_freezing_flatman.sql create mode 100644 db/migrations/meta/0026_snapshot.json create mode 100644 db/migrations/meta/0027_snapshot.json create mode 100644 src/app/components/campaign-analytics/campaign-analytics.component.html create mode 100644 src/app/components/campaign-analytics/campaign-analytics.component.scss create mode 100644 src/app/components/campaign-analytics/campaign-analytics.component.ts create mode 100644 src/app/components/campaign-settings/campaign-settings.component.html create mode 100644 src/app/components/campaign-settings/campaign-settings.component.scss create mode 100644 src/app/components/campaign-settings/campaign-settings.component.ts diff --git a/db/migrations/0026_mighty_moondragon.sql b/db/migrations/0026_mighty_moondragon.sql new file mode 100644 index 0000000..24c204e --- /dev/null +++ b/db/migrations/0026_mighty_moondragon.sql @@ -0,0 +1,3 @@ +ALTER TABLE "campaigns" ALTER COLUMN "description" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "campaigns" ALTER COLUMN "categories" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "campaigns" ALTER COLUMN "redirect_url" DROP NOT NULL; \ No newline at end of file diff --git a/db/migrations/0027_freezing_flatman.sql b/db/migrations/0027_freezing_flatman.sql new file mode 100644 index 0000000..7ea8b18 --- /dev/null +++ b/db/migrations/0027_freezing_flatman.sql @@ -0,0 +1,5 @@ +ALTER TABLE "campaigns" ALTER COLUMN "media" SET DEFAULT '{}';--> statement-breakpoint +ALTER TABLE "campaigns" ALTER COLUMN "links" SET DEFAULT '{}';--> statement-breakpoint +ALTER TABLE "campaigns" ALTER COLUMN "emails" SET DEFAULT '{}';--> statement-breakpoint +ALTER TABLE "campaigns" ALTER COLUMN "phones" SET DEFAULT '{}';--> statement-breakpoint +ALTER TABLE "campaigns" ALTER COLUMN "categories" SET DEFAULT '{}'; \ No newline at end of file diff --git a/db/migrations/meta/0026_snapshot.json b/db/migrations/meta/0026_snapshot.json new file mode 100644 index 0000000..c03f698 --- /dev/null +++ b/db/migrations/meta/0026_snapshot.json @@ -0,0 +1,1455 @@ +{ + "id": "a96f7139-914d-4354-ba8a-769e184e3739", + "prevId": "6387c845-a763-4de3-a1b8-db6ad7fd1066", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.campaign_publications": { + "name": "campaign_publications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "campaign_publications_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "campaign": { + "name": "campaign", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "publish_after": { + "name": "publish_after", + "type": "date", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "publish_before": { + "name": "publish_before", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "campaign_publications_campaign_campaigns_id_fk": { + "name": "campaign_publications_campaign_campaigns_id_fk", + "tableFrom": "campaign_publications", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaigns": { + "name": "campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "campaigns_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media": { + "name": "media", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "links": { + "name": "links", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "emails": { + "name": "emails", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "phones": { + "name": "phones", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "categories": { + "name": "categories", + "type": "bigint[]", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "campaigns_created_by_users_id_fk": { + "name": "campaigns_created_by_users_id_fk", + "tableFrom": "campaigns", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "categories_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "payment_method_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "payment_method_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payment_methods_provider_owner_index": { + "name": "payment_methods_provider_owner_index", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_owner_users_id_fk": { + "name": "payment_methods_owner_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "owner" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_transactions": { + "name": "payment_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_transaction_id": { + "name": "external_transaction_id", + "type": "varchar(400)", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inbound": { + "name": "inbound", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transactions": { + "name": "wallet_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from": { + "name": "from", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_transaction": { + "name": "account_transaction", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_transactions_from_wallets_id_fk": { + "name": "wallet_transactions_from_wallets_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "wallets", + "columnsFrom": [ + "from" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wallet_transactions_to_wallets_id_fk": { + "name": "wallet_transactions_to_wallets_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "wallets", + "columnsFrom": [ + "to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wallet_transactions_account_transaction_payment_transactions_id_fk": { + "name": "wallet_transactions_account_transaction_payment_transactions_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "payment_transactions", + "columnsFrom": [ + "account_transaction" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owned_by": { + "name": "owned_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "starting_balance": { + "name": "starting_balance", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "wallets_owned_by_users_id_fk": { + "name": "wallets_owned_by_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "owned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_tokens": { + "name": "access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_tokens_user_users_id_fk": { + "name": "access_tokens_user_users_id_fk", + "tableFrom": "access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_tokens_replaced_by_access_tokens_id_fk": { + "name": "access_tokens_replaced_by_access_tokens_id_fk", + "tableFrom": "access_tokens", + "tableTo": "access_tokens", + "columnsFrom": [ + "replaced_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_connections": { + "name": "account_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "account_connection_providers", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "account_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_connections_user_users_id_fk": { + "name": "account_connections_user_users_id_fk", + "tableFrom": "account_connections", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federated_credentials": { + "name": "federated_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_access_token": { + "name": "last_access_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_tokens_token_user_index": { + "name": "refresh_tokens_token_user_index", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_users_id_fk": { + "name": "refresh_tokens_user_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refresh_tokens_replaced_by_refresh_tokens_id_fk": { + "name": "refresh_tokens_replaced_by_refresh_tokens_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "refresh_tokens", + "columnsFrom": [ + "replaced_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_tokens_revoked_by_users_id_fk": { + "name": "refresh_tokens_revoked_by_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "revoked_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_tokens_access_token_access_tokens_id_fk": { + "name": "refresh_tokens_access_token_access_tokens_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "access_tokens", + "columnsFrom": [ + "access_token" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_unique": { + "name": "refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_prefs": { + "name": "user_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "theme_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_prefs_user_users_id_fk": { + "name": "user_prefs_user_users_id_fk", + "tableFrom": "user_prefs", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "100", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "names": { + "name": "names", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "dob": { + "name": "dob", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "credentials": { + "name": "credentials", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_credentials_federated_credentials_id_fk": { + "name": "users_credentials_federated_credentials_id_fk", + "tableFrom": "users", + "tableTo": "federated_credentials", + "columnsFrom": [ + "credentials" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_codes": { + "name": "verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_codes_hash_unique": { + "name": "verification_codes_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.payment_method_provider": { + "name": "payment_method_provider", + "schema": "public", + "values": [ + "momo" + ] + }, + "public.payment_method_status": { + "name": "payment_method_status", + "schema": "public", + "values": [ + "active", + "inactive", + "re-connection required" + ] + }, + "public.transaction_status": { + "name": "transaction_status", + "schema": "public", + "values": [ + "pending", + "cancelled", + "complete" + ] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": [ + "funding", + "reward", + "withdrawal" + ] + }, + "public.account_connection_providers": { + "name": "account_connection_providers", + "schema": "public", + "values": [ + "telegram" + ] + }, + "public.account_connection_status": { + "name": "account_connection_status", + "schema": "public", + "values": [ + "active", + "inactive", + "reconnect_required" + ] + }, + "public.theme_pref": { + "name": "theme_pref", + "schema": "public", + "values": [ + "system", + "dark", + "light" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.vw_funding_balances": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownedBy": { + "name": "ownedBy", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"wallets\".\"id\", \"wallets\".\"starting_balance\" + SUM(\n CASE\n WHEN (\"wallet_transactions\".\"from\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'funding' and \"wallet_transactions\".\"status\" = 'complete') THEN -\"wallet_transactions\".\"value\"\n WHEN (\"wallet_transactions\".\"to\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'funding' and \"wallet_transactions\".\"status\" = 'complete') THEN \"wallet_transactions\".\"value\"\n ELSE 0\n END\n )::BIGINT as \"balance\", \"wallets\".\"owned_by\" from \"wallets\" left join \"wallet_transactions\" on (\"wallets\".\"id\" = \"wallet_transactions\".\"from\" or \"wallets\".\"id\" = \"wallet_transactions\".\"to\") left join \"users\" on \"wallets\".\"owned_by\" = \"users\".\"id\" group by \"wallets\".\"id\", \"wallets\".\"owned_by\"", + "name": "vw_funding_balances", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_reward_balances": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownedBy": { + "name": "ownedBy", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"wallets\".\"id\", \"wallets\".\"starting_balance\" + SUM(\n CASE\n WHEN (\"wallet_transactions\".\"from\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'reward' and \"wallet_transactions\".\"status\" = 'complete') THEN -\"wallet_transactions\".\"value\"\n WHEN (\"wallet_transactions\".\"to\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'reward' and \"wallet_transactions\".\"status\" = 'complete') THEN \"wallet_transactions\".\"value\"\n ELSE 0\n END\n )::BIGINT as \"balance\", \"wallets\".\"owned_by\" from \"wallets\" left join \"wallet_transactions\" on (\"wallets\".\"id\" = \"wallet_transactions\".\"from\" or \"wallets\".\"id\" = \"wallet_transactions\".\"to\") left join \"users\" on \"wallets\".\"owned_by\" = \"users\".\"id\" group by \"wallets\".\"id\", \"wallets\".\"owned_by\"", + "name": "vw_reward_balances", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_verification_codes": { + "columns": { + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"hash\", \"created_at\", \n (\"created_at\" + \"window\")::TIMESTAMP\n as \"expires_at\", \n (CASE\n WHEN \"confirmed_at\" IS NOT NULL THEN true\n ELSE NOW() > (\"created_at\" + \"window\")\n END)::BOOlEAN\n as \"is_expired\", \"data\" from \"verification_codes\"", + "name": "vw_verification_codes", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_access_tokens": { + "columns": { + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + } + }, + "definition": "select \"user\", (now() > (\"created_at\" + \"window\")::TIMESTAMP)::BOOLEAN OR revoked_at IS NOT NULL OR replaced_by IS NOT NULL as \"is_expired\", (created_at + \"window\")::TIMESTAMP as \"expires_at\", \"created_at\", \"ip\", \"id\" from \"access_tokens\"", + "name": "vw_access_tokens", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_refresh_tokens": { + "columns": { + "revoked_by": { + "name": "revoked_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "access_token": { + "name": "access_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + } + }, + "definition": "select (now()::TIMESTAMP > (\"created_at\" + \"window\")::TIMESTAMP)::BOOLEAN OR \"revoked_by\" IS NOT NULL as \"is_expired\", (\"created_at\" + \"window\")::TIMESTAMP as \"expires\", \"revoked_by\", \"replaced_by\", \"created_at\", \"access_token\", \"ip\", \"user\", \"token\", \"id\" from \"refresh_tokens\"", + "name": "vw_refresh_tokens", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/0027_snapshot.json b/db/migrations/meta/0027_snapshot.json new file mode 100644 index 0000000..7ed6db1 --- /dev/null +++ b/db/migrations/meta/0027_snapshot.json @@ -0,0 +1,1460 @@ +{ + "id": "106064c2-367f-47f2-93a5-f46c13fdf1dc", + "prevId": "a96f7139-914d-4354-ba8a-769e184e3739", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.campaign_publications": { + "name": "campaign_publications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "campaign_publications_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "campaign": { + "name": "campaign", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "publish_after": { + "name": "publish_after", + "type": "date", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "publish_before": { + "name": "publish_before", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "campaign_publications_campaign_campaigns_id_fk": { + "name": "campaign_publications_campaign_campaigns_id_fk", + "tableFrom": "campaign_publications", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaigns": { + "name": "campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "campaigns_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media": { + "name": "media", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "links": { + "name": "links", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "emails": { + "name": "emails", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "phones": { + "name": "phones", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "categories": { + "name": "categories", + "type": "bigint[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "campaigns_created_by_users_id_fk": { + "name": "campaigns_created_by_users_id_fk", + "tableFrom": "campaigns", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "categories_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "payment_method_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "payment_method_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payment_methods_provider_owner_index": { + "name": "payment_methods_provider_owner_index", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_owner_users_id_fk": { + "name": "payment_methods_owner_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "owner" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_transactions": { + "name": "payment_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_transaction_id": { + "name": "external_transaction_id", + "type": "varchar(400)", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inbound": { + "name": "inbound", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transactions": { + "name": "wallet_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from": { + "name": "from", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_transaction": { + "name": "account_transaction", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_transactions_from_wallets_id_fk": { + "name": "wallet_transactions_from_wallets_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "wallets", + "columnsFrom": [ + "from" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wallet_transactions_to_wallets_id_fk": { + "name": "wallet_transactions_to_wallets_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "wallets", + "columnsFrom": [ + "to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wallet_transactions_account_transaction_payment_transactions_id_fk": { + "name": "wallet_transactions_account_transaction_payment_transactions_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "payment_transactions", + "columnsFrom": [ + "account_transaction" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owned_by": { + "name": "owned_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "starting_balance": { + "name": "starting_balance", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "wallets_owned_by_users_id_fk": { + "name": "wallets_owned_by_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "owned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_tokens": { + "name": "access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_tokens_user_users_id_fk": { + "name": "access_tokens_user_users_id_fk", + "tableFrom": "access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_tokens_replaced_by_access_tokens_id_fk": { + "name": "access_tokens_replaced_by_access_tokens_id_fk", + "tableFrom": "access_tokens", + "tableTo": "access_tokens", + "columnsFrom": [ + "replaced_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_connections": { + "name": "account_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "account_connection_providers", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "account_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_connections_user_users_id_fk": { + "name": "account_connections_user_users_id_fk", + "tableFrom": "account_connections", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federated_credentials": { + "name": "federated_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_access_token": { + "name": "last_access_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_tokens_token_user_index": { + "name": "refresh_tokens_token_user_index", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_users_id_fk": { + "name": "refresh_tokens_user_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refresh_tokens_replaced_by_refresh_tokens_id_fk": { + "name": "refresh_tokens_replaced_by_refresh_tokens_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "refresh_tokens", + "columnsFrom": [ + "replaced_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_tokens_revoked_by_users_id_fk": { + "name": "refresh_tokens_revoked_by_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "revoked_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_tokens_access_token_access_tokens_id_fk": { + "name": "refresh_tokens_access_token_access_tokens_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "access_tokens", + "columnsFrom": [ + "access_token" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_unique": { + "name": "refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_prefs": { + "name": "user_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "theme_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_prefs_user_users_id_fk": { + "name": "user_prefs_user_users_id_fk", + "tableFrom": "user_prefs", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "100", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "names": { + "name": "names", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "dob": { + "name": "dob", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "credentials": { + "name": "credentials", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_credentials_federated_credentials_id_fk": { + "name": "users_credentials_federated_credentials_id_fk", + "tableFrom": "users", + "tableTo": "federated_credentials", + "columnsFrom": [ + "credentials" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_codes": { + "name": "verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_codes_hash_unique": { + "name": "verification_codes_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.payment_method_provider": { + "name": "payment_method_provider", + "schema": "public", + "values": [ + "momo" + ] + }, + "public.payment_method_status": { + "name": "payment_method_status", + "schema": "public", + "values": [ + "active", + "inactive", + "re-connection required" + ] + }, + "public.transaction_status": { + "name": "transaction_status", + "schema": "public", + "values": [ + "pending", + "cancelled", + "complete" + ] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": [ + "funding", + "reward", + "withdrawal" + ] + }, + "public.account_connection_providers": { + "name": "account_connection_providers", + "schema": "public", + "values": [ + "telegram" + ] + }, + "public.account_connection_status": { + "name": "account_connection_status", + "schema": "public", + "values": [ + "active", + "inactive", + "reconnect_required" + ] + }, + "public.theme_pref": { + "name": "theme_pref", + "schema": "public", + "values": [ + "system", + "dark", + "light" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.vw_funding_balances": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownedBy": { + "name": "ownedBy", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"wallets\".\"id\", \"wallets\".\"starting_balance\" + SUM(\n CASE\n WHEN (\"wallet_transactions\".\"from\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'funding' and \"wallet_transactions\".\"status\" = 'complete') THEN -\"wallet_transactions\".\"value\"\n WHEN (\"wallet_transactions\".\"to\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'funding' and \"wallet_transactions\".\"status\" = 'complete') THEN \"wallet_transactions\".\"value\"\n ELSE 0\n END\n )::BIGINT as \"balance\", \"wallets\".\"owned_by\" from \"wallets\" left join \"wallet_transactions\" on (\"wallets\".\"id\" = \"wallet_transactions\".\"from\" or \"wallets\".\"id\" = \"wallet_transactions\".\"to\") left join \"users\" on \"wallets\".\"owned_by\" = \"users\".\"id\" group by \"wallets\".\"id\", \"wallets\".\"owned_by\"", + "name": "vw_funding_balances", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_reward_balances": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownedBy": { + "name": "ownedBy", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"wallets\".\"id\", \"wallets\".\"starting_balance\" + SUM(\n CASE\n WHEN (\"wallet_transactions\".\"from\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'reward' and \"wallet_transactions\".\"status\" = 'complete') THEN -\"wallet_transactions\".\"value\"\n WHEN (\"wallet_transactions\".\"to\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'reward' and \"wallet_transactions\".\"status\" = 'complete') THEN \"wallet_transactions\".\"value\"\n ELSE 0\n END\n )::BIGINT as \"balance\", \"wallets\".\"owned_by\" from \"wallets\" left join \"wallet_transactions\" on (\"wallets\".\"id\" = \"wallet_transactions\".\"from\" or \"wallets\".\"id\" = \"wallet_transactions\".\"to\") left join \"users\" on \"wallets\".\"owned_by\" = \"users\".\"id\" group by \"wallets\".\"id\", \"wallets\".\"owned_by\"", + "name": "vw_reward_balances", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_verification_codes": { + "columns": { + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"hash\", \"created_at\", \n (\"created_at\" + \"window\")::TIMESTAMP\n as \"expires_at\", \n (CASE\n WHEN \"confirmed_at\" IS NOT NULL THEN true\n ELSE NOW() > (\"created_at\" + \"window\")\n END)::BOOlEAN\n as \"is_expired\", \"data\" from \"verification_codes\"", + "name": "vw_verification_codes", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_access_tokens": { + "columns": { + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + } + }, + "definition": "select \"user\", (now() > (\"created_at\" + \"window\")::TIMESTAMP)::BOOLEAN OR revoked_at IS NOT NULL OR replaced_by IS NOT NULL as \"is_expired\", (created_at + \"window\")::TIMESTAMP as \"expires_at\", \"created_at\", \"ip\", \"id\" from \"access_tokens\"", + "name": "vw_access_tokens", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_refresh_tokens": { + "columns": { + "revoked_by": { + "name": "revoked_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "access_token": { + "name": "access_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + } + }, + "definition": "select (now()::TIMESTAMP > (\"created_at\" + \"window\")::TIMESTAMP)::BOOLEAN OR \"revoked_by\" IS NOT NULL as \"is_expired\", (\"created_at\" + \"window\")::TIMESTAMP as \"expires\", \"revoked_by\", \"replaced_by\", \"created_at\", \"access_token\", \"ip\", \"user\", \"token\", \"id\" from \"refresh_tokens\"", + "name": "vw_refresh_tokens", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index d16e13f..af39077 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -183,6 +183,20 @@ "when": 1737017991125, "tag": "0025_redundant_swarm", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1737021295964, + "tag": "0026_mighty_moondragon", + "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1737024383091, + "tag": "0027_freezing_flatman", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/schema/campaigns.ts b/db/schema/campaigns.ts index 114beee..ee951b9 100644 --- a/db/schema/campaigns.ts +++ b/db/schema/campaigns.ts @@ -1,25 +1,36 @@ -import { bigint, check, date, integer, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core'; import { sql } from 'drizzle-orm'; -import { createInsertSchema } from 'drizzle-zod'; +import { bigint, check, date, integer, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; import { users } from './users'; -import { z } from 'zod'; export const campaigns = pgTable('campaigns', { id: bigint({ mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(), title: varchar({ length: 255 }).notNull(), - description: text().notNull(), - media: text().array(), - links: text().array(), - emails: text().array(), - phones: text().array(), + description: text(), + media: text().array().default([]), + links: text().array().default([]), + emails: text().array().default([]), + phones: text().array().default([]), createdAt: timestamp({ mode: 'date' }).defaultNow(), updatedAt: timestamp({ mode: 'date' }).defaultNow().$onUpdate(() => new Date()), - categories: bigint({ mode: 'number' }).array().notNull(), + categories: bigint({ mode: 'number' }).array().default([]), createdBy: bigint({ mode: 'number' }).notNull().references(() => users.id), - redirectUrl: varchar({ length: 500 }).notNull() + redirectUrl: varchar({ length: 500 }) }); -export const newCampaignSchema = createInsertSchema(campaigns); +export const CampaignLookupSchema = createSelectSchema(campaigns).pick({ + id: true, + title: true, + updatedAt: true, + categories: true +}).transform(({ categories, title, updatedAt, id }) => { + return { categoryCount: categories?.length ?? 0, title, updatedAt, id }; +}); + +export const newCampaignSchema = createInsertSchema(campaigns).pick({ + title: true, + createdBy: true +}); export const campaignPublications = pgTable('campaign_publications', { id: bigint({ mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(), diff --git a/lib/models/campaign.ts b/lib/models/campaign.ts index a5e0e86..a238713 100644 --- a/lib/models/campaign.ts +++ b/lib/models/campaign.ts @@ -1,20 +1,28 @@ export interface Campaign { + description: string | null id: number; title: string; media: string[]; links: string[]; emails: string[]; phones: string[]; + redirectUrl: string | null; createdAt: Date; updatedAt: Date; categories: number[]; } -export interface LookupCampaignResponse { +export type CampaignLookup = { + categoryCount: number; + title: string; + updatedAt: Date; +} + +export type LookupCampaignResponse = { total: number; page: number; size: number; - data: Campaign[]; + data: CampaignLookup[]; } export interface CampaignPublication { diff --git a/server/functions/campaigns/index.mts b/server/functions/campaigns/index.mts index 28ba35c..3378505 100644 --- a/server/functions/campaigns/index.mts +++ b/server/functions/campaigns/index.mts @@ -2,16 +2,20 @@ import { createCampaign, createCampaignPublication, findCampaignPublications, - findUserCampaigns -} from '@handlers/campaign.mjs'; + findUserCampaign, + lookupUserCampaings +} from '@handlers/campaign.mjs'; import { prepareHandler } from '@helpers/handler.mjs'; -import { jwtAuth } from '@middleware/auth.mjs'; -import { Router } from 'express'; +import { jwtAuth } from '@middleware/auth.mjs'; +import { Router } from 'express'; const router = Router(); -router.get('/', jwtAuth, findUserCampaigns); -router.post('/', jwtAuth, createCampaign); -router.get('/:campaign/publications', jwtAuth, findCampaignPublications); -router.post('/:campaign/publications', jwtAuth, createCampaignPublication); +router.use(jwtAuth); + +router.get('/', lookupUserCampaings); +router.post('/', createCampaign); +router.get('/:campaign/publications', findCampaignPublications); +router.post('/:campaign/publications', createCampaignPublication); +router.get('/:campaign', findUserCampaign); export const handler = prepareHandler('campaigns', router); diff --git a/server/handlers/campaign.mts b/server/handlers/campaign.mts index ba449c4..10548f5 100644 --- a/server/handlers/campaign.mts +++ b/server/handlers/campaign.mts @@ -1,12 +1,13 @@ -import { extractUser } from '@helpers/auth.mjs'; -import { useCampaignsDb } from '@helpers/db.mjs'; -import { handleError } from '@helpers/error.mjs'; -import { LookupCampaignResponse } from '@lib/models/campaign'; -import { useLogger } from '@logger/common'; -import { campaignPublications, campaigns, newCampaignSchema, newPublicationSchema } from '@schemas/campaigns'; -import { count, eq } from 'drizzle-orm'; -import express from 'express'; -import { fromZodError } from 'zod-validation-error'; +import { extractUser } from '@helpers/auth.mjs'; +import { useCampaignsDb } from '@helpers/db.mjs'; +import { handleError } from '@helpers/error.mjs'; +import { LookupCampaignResponse } from '@lib/models/campaign'; +import { useLogger } from '@logger/common'; +import { CampaignLookupSchema, campaignPublications, campaigns, newCampaignSchema, newPublicationSchema } from '@schemas/campaigns'; +import { count, eq } from 'drizzle-orm'; +import express from 'express'; +import { z } from 'zod'; +import { fromZodError } from 'zod-validation-error'; const logger = useLogger({ service: 'campaign' }); @@ -48,7 +49,7 @@ export async function findCampaignPublications(req: express.Request, res: expres export async function createCampaign(req: express.Request, res: express.Response) { const user = extractUser(req); - const { success, data, error } = newCampaignSchema.safeParse({ ...req.body, createdBy: user.id}); + const { success, data, error } = newCampaignSchema.safeParse({ ...req.body, createdBy: user.id }); if (!success) { const msg = fromZodError(error).message; res.status(400).json({ message: msg }); @@ -65,12 +66,18 @@ export async function createCampaign(req: express.Request, res: express.Response } } -export async function findUserCampaigns(req: express.Request, res: express.Response) { +export async function lookupUserCampaings(req: express.Request, res: express.Response) { const db = useCampaignsDb(); const page = Number(req.query['page'] ?? 0); const size = Number(req.query['size'] ?? 10); const user = extractUser(req); const data = await db.query.campaigns.findMany({ + columns: { + id: true, + title: true, + updatedAt: true, + categories: true + }, offset: page * size, limit: size, orderBy: (campaigns, { desc }) => [desc(campaigns.updatedAt)], @@ -80,7 +87,7 @@ export async function findUserCampaigns(req: express.Request, res: express.Respo const total = await db.select({ count: count() }).from(campaigns).where(eq(campaigns.createdBy, user.id)); const responseData = { - data, + data: z.array(CampaignLookupSchema).parse(data), page, total: total[0].count, size @@ -88,3 +95,29 @@ export async function findUserCampaigns(req: express.Request, res: express.Respo res.json(responseData); } + +export async function findUserCampaign(req: express.Request, res: express.Response) { + const id = Number(req.params['campaign']); + + if (isNaN(id)) { + res.status(400).json({ message: 'Invalid campaign ID' }); + return; + } + + logger.info('reading campaign', { id }); + const user = extractUser(req); + try { + const db = useCampaignsDb(); + const data = await db.query.campaigns.findFirst({ + where: (campaign, { and, eq }) => and(eq(campaign.id, id), eq(campaign.createdBy, user.id)) + }); + + if (!data) { + res.status(404).json({ message: 'Not found' }); + return; + } + res.json(data); + } catch (e) { + handleError(e as Error, res); + } +} diff --git a/src/app/app.component.html b/src/app/app.component.html index 6ea170d..3f030ef 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -3,7 +3,7 @@ } -
+
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index e4c364b..cd0223d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -3,13 +3,14 @@ import { AsyncPipe } from '@angular/common'; import { Component, effect, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { select } from '@ngxs/store'; -import { updatePreset, usePreset } from '@primeng/themes'; +import { dt, updatePreset, usePreset } from '@primeng/themes'; import { MessageService } from 'primeng/api'; import { Toast } from 'primeng/toast'; import { map } from 'rxjs'; import { AutoThemePreset, ManualThemePreset } from './app.config'; import { TopBarComponent } from './components/top-bar/top-bar.component'; import { isUserSignedIn, preferences } from './state/user'; +import { Meta } from '@angular/platform-browser'; @Component({ selector: 'tm-root', @@ -22,7 +23,8 @@ export class AppComponent { readonly isSmallDisplay = inject(BreakpointObserver).observe([Breakpoints.HandsetPortrait, Breakpoints.HandsetLandscape]).pipe(map(({ matches }) => matches)) readonly isSignedIn = select(isUserSignedIn); private readonly prefs = select(preferences); - constructor() { + constructor(meta: Meta) { + let tag = meta.addTag({ name: 'theme-color' }); effect(() => { const doc = document.querySelector('html') as HTMLHtmlElement; const { theme } = this.prefs(); @@ -43,6 +45,10 @@ export class AppComponent { break; default: } + + if (tag) { + tag.content = getComputedStyle(document.getElementsByTagName('html')[0]).getPropertyValue('--p-primary-color'); + } }) } } diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 6708842..13b3867 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -10,17 +10,17 @@ export const appRoutes: Routes = [ path: 'auth', loadChildren: () => import('./auth.routes').then(m => m.authRoutes) }, - { - path: 'campaigns/:id', - canActivate: [signedInGuard], - loadComponent: () => import('./pages/campaign/campaign.component').then(m => m.CampaignComponent) - }, { path: 'campaigns', canActivate: [signedInGuard], loadComponent: () => import('./pages/campaigns/campaigns.component').then(m => m.CampaignsComponent), title: 'Campaigns' }, + { + path: 'campaigns/:campaign', + canActivate: [signedInGuard], + loadComponent: () => import('./pages/campaign/campaign.component').then(m => m.CampaignComponent) + }, { canActivate: [signedInGuard], title: 'Dashboard', diff --git a/src/app/components/campaign-analytics/campaign-analytics.component.html b/src/app/components/campaign-analytics/campaign-analytics.component.html new file mode 100644 index 0000000..10fac62 --- /dev/null +++ b/src/app/components/campaign-analytics/campaign-analytics.component.html @@ -0,0 +1 @@ +

campaign-analytics works!

diff --git a/src/app/components/campaign-analytics/campaign-analytics.component.scss b/src/app/components/campaign-analytics/campaign-analytics.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/campaign-analytics/campaign-analytics.component.ts b/src/app/components/campaign-analytics/campaign-analytics.component.ts new file mode 100644 index 0000000..5712b2a --- /dev/null +++ b/src/app/components/campaign-analytics/campaign-analytics.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'tm-campaign-analytics', + imports: [], + templateUrl: './campaign-analytics.component.html', + styleUrl: './campaign-analytics.component.scss' +}) +export class CampaignAnalytics { + +} diff --git a/src/app/components/campaign-form/campaign-form.component.html b/src/app/components/campaign-form/campaign-form.component.html index 2d612cc..b666134 100644 --- a/src/app/components/campaign-form/campaign-form.component.html +++ b/src/app/components/campaign-form/campaign-form.component.html @@ -1,296 +1,19 @@ -
- - - Basic - Contacts & Links - Media - - - - -
-
- -
-
- - - -
- @if (newCampaignForm.controls.basic.controls.title.invalid && - newCampaignForm.controls.basic.controls.title.dirty) { - - @if (newCampaignForm.controls.basic.controls.title.hasError('required')) { - This field is required - } @else if (newCampaignForm.controls.basic.controls.title.hasError('maxlength')) { - - } - - } -
-
-
- -
-
- - - -
- @if (newCampaignForm.controls.basic.controls.description.invalid && - newCampaignForm.controls.basic.controls.description.dirty) { - - @if (newCampaignForm.controls.basic.controls.description.hasError('required')) { - This field is required - } @else if (newCampaignForm.controls.basic.controls.description.hasError('maxlength')) { - - } - - } -
-
-
- -
-
- - - -
-
- -
-
- - - - @if(basicControls.redirectUrl.dirty && basicControls.redirectUrl.invalid) { - - @if(basicControls.redirectUrl.hasError('required')) { - This field is required - }@else if (basicControls.redirectUrl.hasError('pattern')) { - Invalid URL - } - - } -
-
- -
- Next - -
-
-
- - -
-
- -
-
-
- @for (control of newCampaignForm.controls.contactsAndLinks.controls.emails.controls; track control) { - -
-
- - - - @if (control.invalid && control.dirty) { -
- - @if (control.hasError('required')) { - This field is required - } @else if (control.hasError('email')) { - Invalid email address - } - -
- } -
- @if ($count > 1 && !$last) { - - } -
-
- } -
-
-
- -
-
-
- @for (group of newCampaignForm.controls.contactsAndLinks.controls.phones.controls; track group) { - -
-
-
-
- - -
- - +{{ item.callingCodes[0] }} -
-
- -
- - {{ country.nativeName }} -
-
-
-
- -
- @if (group.invalid && group.dirty) { -
- - @if (group.controls.code.hasError('required')) { - The country code is required - } @else if (group.controls.number.hasError('number')) { - Invalid national number value - } @else if (group.hasError('phoneInvalid')) { - Invalid phone number - } - -
- } -
- @if ($count > 1 && !$last) { - - } -
-
- } -
-
-
- -
-
-
- @for (control of newCampaignForm.controls.contactsAndLinks.controls.links.controls; track control) { -
-
- - - - @if (control.invalid && control.dirty) { -
- - @if (control.hasError('pattern')) { - Invalid URL - } - -
- } -
- @if (!$last) { - - } -
- } -
-
-
-
- Back - - Next - -
-
-
- - -
-
- - - -

Drag and drop a file here to upload.

-
- -
-
- - - -
-
- - - -
- -
-
- - @if (newCampaignForm.controls.media.length > 0) { -

Uploaded files

-
- @for (url of newCampaignForm.controls.media.value; track url) { -
-
- @if (url.endsWith('.mp4')) { - - } @else { - media-{{$index}} - } -
- -
-
-
- } -
- } -
-
-
-
-
- Back - - Finish - -
-
-
-
-
+ +
+ +
+
+ + + + @if(form.controls.title.dirty && form.controls.title.invalid) { + + This field is required + + } +
+
+ +
- - - {{ error.actualLength }}/{{ error.requiredLength }} characters - diff --git a/src/app/components/campaign-form/campaign-form.component.ts b/src/app/components/campaign-form/campaign-form.component.ts index 605a1c0..4b85455 100644 --- a/src/app/components/campaign-form/campaign-form.component.ts +++ b/src/app/components/campaign-form/campaign-form.component.ts @@ -1,272 +1,47 @@ -import { Component, computed, effect, inject, input, model, output, signal, viewChild } from '@angular/core'; -import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { Step, StepList, StepPanel, StepPanels, Stepper } from 'primeng/stepper'; -import { Fluid } from 'primeng/fluid'; -import { InputText } from 'primeng/inputtext'; -import { Message } from 'primeng/message'; -import { MultiSelect } from 'primeng/multiselect'; -import { Button } from 'primeng/button'; -import { Select } from 'primeng/select'; -import { PhoneDirective } from '@app/directives/phone.directive'; -import { FileRemoveEvent, FileSelectEvent, FileUpload, FileUploadEvent } from 'primeng/fileupload'; -import { MeterGroup } from 'primeng/metergroup'; -import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { PhoneNumberFormat, PhoneNumberUtil } from 'google-libphonenumber'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { Category } from '@lib/models/category'; -import { Divider } from 'primeng/divider'; -import { CountryData } from '@lib/models/country-data'; -import { Textarea } from 'primeng/textarea'; -import { phoneValidator } from '@app/util/phone-valiator'; - -function newMediaControl(url: string) { - return new FormControl(url, { nonNullable: true }); -} - -function newEmailControl() { - return new FormControl('', { - nonNullable: true, - validators: [Validators.maxLength(100), Validators.email] - }) -} - -function newPhoneControl(defaultCode = 'CM') { - return new FormGroup({ - code: new FormControl(defaultCode, { - nonNullable: true, - }), - number: new FormControl('', { nonNullable: false }) - }, [phoneValidator()]) -} - -function newLinkControl() { - return new FormControl('', [Validators.pattern(/^((http|https|ftp):\/\/)?(([\w-]+\.)+[\w-]+)(:\d+)?(\/[\w-]*)*(\?[\w-=&]*)?(#[\w-]*)?$/)]) -} - -const maxUploadSize = 6291456; +import { HttpClient, HttpErrorResponse } from "@angular/common/http"; +import { Component, inject, output, signal } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { Button } from "primeng/button"; +import { Fluid } from "primeng/fluid"; +import { InputText } from "primeng/inputtext"; +import { Message } from "primeng/message"; @Component({ selector: 'tm-campaign-form', + templateUrl: './campaign-form.component.html', + styleUrl: './campaign-form.component.scss', imports: [ ReactiveFormsModule, - Stepper, - Fluid, - StepList, - Step, - StepPanels, - StepPanel, - InputText, - Message, - MultiSelect, Button, - Select, - PhoneDirective, - FileUpload, - MeterGroup, - Divider, - Textarea - ], - templateUrl: './campaign-form.component.html', - styleUrl: './campaign-form.component.scss' + InputText, + Fluid, + Message + ] }) export class CampaignFormComponent { private http = inject(HttpClient); - private uploadComponent = viewChild(FileUpload); - newCampaignFormStep = model(1); - selectedFiles = signal([]); - maxFileSize = computed(() => { - const selected = this.selectedFiles(); - const totalSize = selected.reduce((a, b) => a + b.size, 0); - return Math.max(maxUploadSize - totalSize, 0); - }); - maxUploadCount = computed(() => { - const maxSize = this.maxFileSize(); - return Math.floor(maxSize / (maxUploadSize / 10)); + submitting = signal(false); + onSubmitted = output(); + onErrored = output(); + form = new FormGroup({ + title: new FormControl('', { nonNullable: true, validators: [Validators.required] }) }); - uploadConstraints = computed(() => [{ - label: 'Upload limit (6MB)', - value: this.selectedFiles().reduce((acc, curr) => acc + curr.size, 0), - color: 'var(--p-primary-color)' - }]); - readonly categories = input.required(); - readonly countries = input.required(); - readonly categoriesLoading = input(false); - readonly countriesLoading = input(false); - readonly submitting = signal(false); - readonly error = output(); - readonly onSubmit = output(); - readonly submissionPending = signal(false); - readonly uploading = signal(false); - readonly newCampaignForm = new FormGroup({ - basic: new FormGroup({ - title: new FormControl('', [Validators.required, Validators.maxLength(255)]), - description: new FormControl('', [Validators.required, Validators.maxLength(2000)]), - categories: new FormControl([], [Validators.required, Validators.minLength(1)]), - redirectUrl: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.pattern(/^https?:\/\/[^\s/$.?#].[^\s]*$/)] }) - }), - contactsAndLinks: new FormGroup({ - emails: new FormArray>([newEmailControl()]), - phones: new FormArray; - number: FormControl - }>>([newPhoneControl()]), - links: new FormArray>([newLinkControl()]) - }), - media: new FormArray>([]) - }); - get basicControls() { - return this.newCampaignForm.controls.basic.controls; - } - - onMediaFileSelected(event: FileSelectEvent) { - this.selectedFiles.update(() => [...event.currentFiles]); - } - - onMediaFileRemoved(event: FileRemoveEvent) { - this.selectedFiles.update(files => [...files.filter(f => f.name != event.file.name)]); - } - - onBeforeUpload() { - this.uploading.set(true); - } - onMediaFilesUploaded({ originalEvent, files }: FileUploadEvent) { - this.uploading.set(false); - const { body } = originalEvent as HttpResponse; - if (!body) return; - - for (const url of body) { - this.newCampaignForm.controls.media.insert(0, newMediaControl(url)); - } - - this.selectedFiles.update(current => { - const ans = Array(); - for (const file of current) { - if (files.some(f => f.name === file.name)) continue; - ans.push(file); - } - return ans; - }); - } - - onMediaFilesCleared() { - this.selectedFiles.set([]); - } - - addEmailControl() { - this.newCampaignForm.controls.contactsAndLinks.controls.emails.push(newEmailControl()); - } - - removeEmailControl(index: number) { - this.newCampaignForm.controls.contactsAndLinks.controls.emails.removeAt(index); - } - - addPhoneControl() { - this.newCampaignForm.controls.contactsAndLinks.controls.phones.push(newPhoneControl(this.newCampaignForm.controls.contactsAndLinks.controls.phones.at(-1)?.value.code ?? 'CM')); - } - - removePhoneControl(index: number) { - this.newCampaignForm.controls.contactsAndLinks.controls.phones.removeAt(index); - } - - addLinkControl() { - this.newCampaignForm.controls.contactsAndLinks.controls.links.push(newLinkControl()); - } - - removeLinkControl(index: number) { - this.newCampaignForm.controls.contactsAndLinks.controls.links.removeAt(index); - } - - removeMediaControl(index: number) { - this.newCampaignForm.controls.media.removeAt(index); - } - - onFormSubmit(event: SubmitEvent) { - event.preventDefault(); - } - - onAdvanceButtonClicked(step: number, callback: (n: number) => void, event: Event) { - event.preventDefault(); - callback(step); - } - - onFinishButtonClicked() { + onCreateCampaignButtonClicked() { this.submitting.set(true); - if (this.uploadComponent()?.hasFiles()) { - this.uploadComponent()?.uploader(); - this.submissionPending.set(true); - return - } - this.doSubmission(); - } - - private doSubmission() { - this.submissionPending.set(false); - const { basic, media, contactsAndLinks } = this.newCampaignForm.value; - const phoneUtil = PhoneNumberUtil.getInstance(); - this.http.post('/api/campaigns', { - redirectUrl: String(basic?.redirectUrl), - title: String(basic?.title), - description: String(basic?.description), - media: media ?? [], - links: contactsAndLinks?.links?.filter(x => (x?.length ?? 0) > 0) ?? [], - emails: contactsAndLinks?.emails?.filter(x => (x?.length ?? 0) > 0) ?? [], - phones: contactsAndLinks?.phones?.filter((_, i, arr) => i < arr.length - 1).map(v => { - const code = v.code ?? 'CM'; - const number = String(v.number); - const phone = phoneUtil.parse(number, code); - return phoneUtil.format(phone, PhoneNumberFormat.E164); - }), - categories: basic?.categories ?? [] - }).subscribe({ + const val = this.form.value; + this.http.post('/api/campaigns', val).subscribe({ error: (error: HttpErrorResponse) => { - this.error.emit(error); this.submitting.set(false); + this.onErrored.emit(error.error ?? error); }, complete: () => { - this.newCampaignForm.controls.contactsAndLinks.controls.links.clear(); - this.addLinkControl(); - this.newCampaignForm.reset() - this.newCampaignForm.controls.contactsAndLinks.controls.emails.clear(); - this.addEmailControl(); - this.newCampaignForm.controls.contactsAndLinks.controls.phones.clear(); - this.addPhoneControl(); - this.newCampaignForm.controls.media.clear(); - this.selectedFiles.set([]); - this.newCampaignForm.controls.basic.reset(); - this.newCampaignForm.reset(); - this.newCampaignFormStep.set(1); - this.onSubmit.emit(); - this.submitting.set(false); + this.onSubmitted.emit(); } - }) + }); } - constructor() { - this.newCampaignForm.controls.contactsAndLinks.controls.links.valueChanges.pipe( - takeUntilDestroyed() - ).subscribe(values => { - if (!values.every(v => (v?.length ?? 0) > 0)) return; - this.addLinkControl(); - }) - this.newCampaignForm.controls.contactsAndLinks.controls.phones.valueChanges.pipe( - takeUntilDestroyed() - ).subscribe(values => { - if (!values.every(v => (v.number?.length ?? 0) > 0)) return; - this.addPhoneControl(); - }) - this.newCampaignForm.controls.contactsAndLinks.controls.emails.valueChanges.pipe( - takeUntilDestroyed(), - ).subscribe(values => { - if (!values.every(v => v.length > 0)) return; - this.addEmailControl(); - }); - effect(() => { - const submissionPending = this.submissionPending(); - const uploading = this.uploading(); - if (submissionPending && !uploading) { - this.doSubmission(); - } - }) + onFormSubmit(event: SubmitEvent) { + event.preventDefault(); } } diff --git a/src/app/components/campaign-publications/campaign-publications.component.ts b/src/app/components/campaign-publications/campaign-publications.component.ts index a86dcf4..05ba4d7 100644 --- a/src/app/components/campaign-publications/campaign-publications.component.ts +++ b/src/app/components/campaign-publications/campaign-publications.component.ts @@ -13,7 +13,7 @@ import { of } from 'rxjs'; templateUrl: './campaign-publications.component.html', styleUrl: './campaign-publications.component.scss' }) -export class CampaignPublicationsComponent { +export class CampaignPublications { private http = inject(HttpClient); readonly campaignId = input(); readonly publications: ResourceRef = rxResource({ diff --git a/src/app/components/campaign-settings/campaign-settings.component.html b/src/app/components/campaign-settings/campaign-settings.component.html new file mode 100644 index 0000000..c9b02d5 --- /dev/null +++ b/src/app/components/campaign-settings/campaign-settings.component.html @@ -0,0 +1,193 @@ +
+
+ +
+
+ +
+
+ + +

{{ campaign()?.title ?? 'Not set' }}

+
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+ + +

{{ campaign()?.description ?? 'Not set' }}

+
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+ + + @let count = campaign()?.categories?.length ?? 0; +

+ Not set + 1 category + {{count}} categories + 99+ categories +

+
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+ + +

{{ campaign()?.redirectUrl ?? 'Not set' }}

+
+ +
+ +
+ +
+
+
+
+
+
+
+
+
+ + + + Links + +
+ @for (linkControl of linkControls; track $index) { + + } +
+
+
+ + Phone numbers + +
+ @for(phoneGroup of phoneControls;track $index){ + + } +
+
+
+ + Email addresses + + emails + + +
+
+
+ +
+ + + +
+
+ +
+

Deleting a campaign will invalidate all active publications and release all remaining allocated credits

+
+
+
+
+
+ + +
+
+
+ + +
+ + +{{ item.callingCodes[0] }} +
+
+ +
+ + {{ country.nativeName }} +
+
+
+ +
+ @if(group.invalid && group.dirty) { + + @if(group.hasError('phoneInvalid')){ + Invalid phone number + } + + } +
+ @if(length > 0 && index + < length-1) { + } +
+
+ + +
+
+ + @if(control.invalid && control.dirty) { + + @if(control.hasError('pattern')){ + Invalid URL + } + + } +
+ @if(length > 0 && index + < length-1) { + } +
+
diff --git a/src/app/components/campaign-settings/campaign-settings.component.scss b/src/app/components/campaign-settings/campaign-settings.component.scss new file mode 100644 index 0000000..249c71a --- /dev/null +++ b/src/app/components/campaign-settings/campaign-settings.component.scss @@ -0,0 +1,3 @@ +:host { + @apply block; +} diff --git a/src/app/components/campaign-settings/campaign-settings.component.ts b/src/app/components/campaign-settings/campaign-settings.component.ts new file mode 100644 index 0000000..5054752 --- /dev/null +++ b/src/app/components/campaign-settings/campaign-settings.component.ts @@ -0,0 +1,145 @@ +import { NgPlural, NgPluralCase, NgTemplateOutlet } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { AfterViewInit, Component, inject, input } from '@angular/core'; +import { rxResource, takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { PhoneDirective } from '@app/directives/phone.directive'; +import { phoneValidator } from '@app/util/phone-valiator'; +import { Campaign } from '@lib/models/campaign'; +import { Category } from '@lib/models/category'; +import { CountryData } from '@lib/models/country-data'; +import { PhoneNumberFormat, PhoneNumberUtil } from 'google-libphonenumber'; +import { AccordionModule, } from 'primeng/accordion'; +import { Button } from 'primeng/button'; +import { Fieldset } from 'primeng/fieldset'; +import { Inplace } from 'primeng/inplace'; +import { InputText } from 'primeng/inputtext'; +import { Message } from 'primeng/message'; +import { MultiSelect } from 'primeng/multiselect'; +import { Select } from 'primeng/select'; +import { Textarea } from 'primeng/textarea'; +const phoneUtil = PhoneNumberUtil.getInstance(); + +function newAttachmentControl(url: string) { + return new FormControl(url, { nonNullable: true }); +} + +function newEmailControl() { + return new FormControl('', { + nonNullable: true, + validators: [Validators.maxLength(100), Validators.email] + }); +} + +function newPhoneControl(defaultCode = 'CM') { + return new FormGroup({ + code: new FormControl(defaultCode, { + nonNullable: true, + }), + number: new FormControl('', { nonNullable: false }) + }, [phoneValidator()]); +} + +function newLinkControl() { + return new FormControl('', [Validators.pattern(/^((http|https|ftp):\/\/)?(([\w-]+\.)+[\w-]+)(:\d+)?(\/[\w-]*)*(\?[\w-=&]*)?(#[\w-]*)?$/)]) +} + +@Component({ + selector: 'tm-campaign-settings', + imports: [PhoneDirective, Select, NgTemplateOutlet, ReactiveFormsModule, Message, AccordionModule, Fieldset, Textarea, Inplace, InputText, Button, MultiSelect, NgPlural, NgPluralCase], + templateUrl: './campaign-settings.component.html', + styleUrl: './campaign-settings.component.scss' +}) +export class CampaignSettings implements AfterViewInit { + private http = inject(HttpClient); + readonly campaign = input(); + readonly categories = rxResource({ + loader: () => this.http.get('/api/categories') + }); + readonly countries = rxResource({ + loader: () => this.http.get('/api/countries') + }); + readonly form = new FormGroup({ + general: new FormGroup({ + title: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + categories: new FormControl([], { nonNullable: true, validators: [Validators.required] }), + redirectUrl: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.pattern(/^((http|https|ftp):\/\/)?(([\w-]+\.)+[\w-]+)(:\d+)?(\/[\w-]*)*(\?[\w-=&]*)?(#[\w-]*)?$/)] }) + }), + linksAndContacts: new FormGroup({ + links: new FormArray>([newLinkControl()]), + phoneNumbers: new FormArray; + number: FormControl; + }>>([newPhoneControl()]), + emails: new FormArray>([newEmailControl()]) + }), + attachments: new FormArray>([]) + }); + + get basicControls() { + return this.form.controls.general.controls; + } + + get linkControls() { + return this.form.controls.linksAndContacts.controls.links.controls; + } + + get phoneControls() { + return this.form.controls.linksAndContacts.controls.phoneNumbers.controls; + } + + getSamplePhoneNumber(code: string) { + const p = phoneUtil.getExampleNumber(code); + return phoneUtil.format(p, PhoneNumberFormat.NATIONAL); + } + + ngAfterViewInit(): void { + this.form.patchValue({}) + } + + addLinkControl() { + this.form.controls.linksAndContacts.controls.links.push(newLinkControl()); + } + + removeLinkControl(index: number) { + this.form.controls.linksAndContacts.controls.links.removeAt(index); + } + + addPhoneControl() { + this.form.controls.linksAndContacts.controls.phoneNumbers.push(newPhoneControl(this.form.controls.linksAndContacts.controls.phoneNumbers.at(-1)?.value.code ?? 'CM')); + } + + removePhoneControl(index: number) { + this.form.controls.linksAndContacts.controls.phoneNumbers.removeAt(index); + } + + addEmailControl() { + this.form.controls.linksAndContacts.controls.emails.push(newEmailControl()); + } + + removeEmailControl(index: number) { + this.form.controls.linksAndContacts.controls.emails.removeAt(index); + } + + constructor() { + this.form.controls.linksAndContacts.controls.links.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(values => { + if (!values.every(v => (v?.length ?? 0) > 0)) return; + this.addLinkControl(); + }); + + this.form.controls.linksAndContacts.controls.phoneNumbers.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(values => { + if (!values.every(v => (v.number?.length ?? 0) > 0)) return; + this.addPhoneControl(); + }) + this.form.controls.linksAndContacts.controls.emails.valueChanges.pipe( + takeUntilDestroyed(), + ).subscribe(values => { + if (!values.every(v => v.length > 0)) return; + this.addEmailControl(); + }); + } +} diff --git a/src/app/pages/campaign/campaign.component.html b/src/app/pages/campaign/campaign.component.html index 40d56c9..606b614 100644 --- a/src/app/pages/campaign/campaign.component.html +++ b/src/app/pages/campaign/campaign.component.html @@ -1 +1,29 @@ -

campaign works!

+@defer (when !campaign.isLoading()) { +
+

{{ campaign.value()?.title }}

+ + + Analytics + Publications + Settings + + + + + + + + + + + + + +
+ +}@placeholder { +
+ +
+} diff --git a/src/app/pages/campaign/campaign.component.scss b/src/app/pages/campaign/campaign.component.scss index e69de29..2dbc216 100644 --- a/src/app/pages/campaign/campaign.component.scss +++ b/src/app/pages/campaign/campaign.component.scss @@ -0,0 +1,3 @@ +:host { + @apply block h-full; +} diff --git a/src/app/pages/campaign/campaign.component.ts b/src/app/pages/campaign/campaign.component.ts index b547a93..2743a03 100644 --- a/src/app/pages/campaign/campaign.component.ts +++ b/src/app/pages/campaign/campaign.component.ts @@ -1,11 +1,50 @@ -import { Component } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Component, computed, inject } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; +import { CampaignAnalytics } from '@app/components/campaign-analytics/campaign-analytics.component'; +import { CampaignPublications } from '@app/components/campaign-publications/campaign-publications.component'; +import { CampaignSettings } from '@app/components/campaign-settings/campaign-settings.component'; +import { Campaign } from '@lib/models/campaign'; +import { injectParams } from 'ngxtension/inject-params'; +import { injectQueryParams } from 'ngxtension/inject-query-params'; +import { ProgressSpinner } from 'primeng/progressspinner'; +import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; +import { EMPTY } from 'rxjs'; @Component({ selector: 'tm-campaign', - imports: [], + imports: [ + Tabs, + TabList, + Tab, + CampaignAnalytics, + CampaignPublications, + CampaignSettings, + TabPanel, + ProgressSpinner, + TabPanels + ], templateUrl: './campaign.component.html', styleUrl: './campaign.component.scss' }) export class CampaignComponent { - + private http = inject(HttpClient); + readonly tabParam = injectQueryParams('activeTab', { initialValue: 'analytics' }); + readonly activeTabIndex = computed(() => { + const tabParam = this.tabParam(); + switch (tabParam) { + default: + case 'general': return 0; + case 'publications': return 1; + case 'settings': return 2; + } + }); + readonly campaignId = injectParams('campaign'); + readonly campaign = rxResource({ + request: () => this.campaignId(), + loader: ({ request }) => { + if (!request) return EMPTY; + return this.http.get(`/api/campaigns/${request}`) + } + }); } diff --git a/src/app/pages/campaigns/campaigns.component.html b/src/app/pages/campaigns/campaigns.component.html index 935783c..0ff081e 100644 --- a/src/app/pages/campaigns/campaigns.component.html +++ b/src/app/pages/campaigns/campaigns.component.html @@ -2,7 +2,7 @@

Campaigns

- @@ -14,7 +14,8 @@

Campaigns

- +
@@ -30,11 +31,13 @@

Campaigns

{{ campaign.title }} + class="line-clamp-1 overflow-ellipsis hover:text-primary transition-colors inline">{{ campaign.title + }} - + + Not set 1 category - {{ campaign.categories.length }} categories + {{ campaign.categoryCount }} categories 99+ categories {{ campaign.updatedAt | date }} @@ -47,11 +50,11 @@

Campaigns

- +

New Campaign

- -
+ + diff --git a/src/app/pages/campaigns/campaigns.component.ts b/src/app/pages/campaigns/campaigns.component.ts index 1f8a321..ba1cad2 100644 --- a/src/app/pages/campaigns/campaigns.component.ts +++ b/src/app/pages/campaigns/campaigns.component.ts @@ -17,6 +17,7 @@ import { import { MessageService, ToastMessageOptions } from 'primeng/api'; import { Button } from 'primeng/button'; import { DataViewModule } from 'primeng/dataview'; +import { Dialog } from 'primeng/dialog'; import { Drawer } from 'primeng/drawer'; import { IconField } from 'primeng/iconfield'; import { InputIcon } from 'primeng/inputicon'; @@ -33,16 +34,16 @@ import { TableModule } from 'primeng/table'; InputText, IconField, InputIcon, - Drawer, ReactiveFormsModule, DatePipe, Panel, DataViewModule, - CampaignFormComponent, NgPlural, NgPluralCase, RouterLink, - Ripple + Dialog, + Ripple, + CampaignFormComponent ], templateUrl: './campaigns.component.html', styleUrl: './campaigns.component.scss' @@ -56,13 +57,6 @@ export class CampaignsComponent { currentPageSize = model(20); readonly tokens = signal(0); - readonly categories = rxResource({ - loader: () => this.http.get('/api/categories') - }); - readonly countries = rxResource({ - loader: () => this.http.get('/api/countries') - }); - readonly campaigns: ResourceRef = rxResource({ request: () => ({ page: this.currentPage(), size: this.currentPageSize() }), loader: ({ request: { page, size } }) => this.http.get('/api/campaigns', { @@ -73,6 +67,7 @@ export class CampaignsComponent { }) }); + onCampaignFormSubmitted() { this.campaigns.reload(); this.showCampaignModal.set(false); @@ -92,7 +87,7 @@ export class CampaignsComponent { constructor() { effect(() => { - const fetchError = this.categories.error(); + const fetchError = this.campaigns.error(); if (!fetchError) return; this.messageService.add({ severity: 'error', From 131150b867447cca9712d5403a07a4c6a0c9249e Mon Sep 17 00:00:00 2001 From: Conrad Bekondo Date: Sat, 18 Jan 2025 03:58:28 +0100 Subject: [PATCH 4/4] feat: deletion support for campaigns --- db/migrations/0028_odd_mephisto.sql | 1 + db/migrations/meta/0028_snapshot.json | 1460 +++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema/campaigns.ts | 29 +- netlify.toml | 4 + package.json | 3 + pnpm-lock.yaml | 554 ++++++- server/functions/blob/index.mts | 8 +- server/functions/campaigns/index.mts | 6 +- server/handlers/blob.mts | 213 ++- server/handlers/campaign.mts | 96 +- server/middleware/auth.mts | 2 - server/schemas/campaigns.mts | 22 + .../campaign-settings.component.html | 182 +- .../campaign-settings.component.ts | 391 ++++- .../pages/campaign/campaign.component.html | 80 +- src/app/pages/campaign/campaign.component.ts | 47 +- .../pages/campaigns/campaigns.component.html | 2 +- 18 files changed, 2975 insertions(+), 132 deletions(-) create mode 100644 db/migrations/0028_odd_mephisto.sql create mode 100644 db/migrations/meta/0028_snapshot.json diff --git a/db/migrations/0028_odd_mephisto.sql b/db/migrations/0028_odd_mephisto.sql new file mode 100644 index 0000000..4b89281 --- /dev/null +++ b/db/migrations/0028_odd_mephisto.sql @@ -0,0 +1 @@ +ALTER TABLE "campaign_publications" RENAME COLUMN "tokens" TO "credits"; \ No newline at end of file diff --git a/db/migrations/meta/0028_snapshot.json b/db/migrations/meta/0028_snapshot.json new file mode 100644 index 0000000..c6131dd --- /dev/null +++ b/db/migrations/meta/0028_snapshot.json @@ -0,0 +1,1460 @@ +{ + "id": "8378428a-27c0-4786-bae2-76da2289efb5", + "prevId": "106064c2-367f-47f2-93a5-f46c13fdf1dc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.campaign_publications": { + "name": "campaign_publications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "campaign_publications_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "campaign": { + "name": "campaign", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "publish_after": { + "name": "publish_after", + "type": "date", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "publish_before": { + "name": "publish_before", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "campaign_publications_campaign_campaigns_id_fk": { + "name": "campaign_publications_campaign_campaigns_id_fk", + "tableFrom": "campaign_publications", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaigns": { + "name": "campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "campaigns_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "media": { + "name": "media", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "links": { + "name": "links", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "emails": { + "name": "emails", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "phones": { + "name": "phones", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "categories": { + "name": "categories", + "type": "bigint[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "campaigns_created_by_users_id_fk": { + "name": "campaigns_created_by_users_id_fk", + "tableFrom": "campaigns", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "categories_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "payment_method_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "payment_method_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "owner": { + "name": "owner", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payment_methods_provider_owner_index": { + "name": "payment_methods_provider_owner_index", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_owner_users_id_fk": { + "name": "payment_methods_owner_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "owner" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_transactions": { + "name": "payment_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_transaction_id": { + "name": "external_transaction_id", + "type": "varchar(400)", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "inbound": { + "name": "inbound", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transactions": { + "name": "wallet_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from": { + "name": "from", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_transaction": { + "name": "account_transaction", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_transactions_from_wallets_id_fk": { + "name": "wallet_transactions_from_wallets_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "wallets", + "columnsFrom": [ + "from" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wallet_transactions_to_wallets_id_fk": { + "name": "wallet_transactions_to_wallets_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "wallets", + "columnsFrom": [ + "to" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "wallet_transactions_account_transaction_payment_transactions_id_fk": { + "name": "wallet_transactions_account_transaction_payment_transactions_id_fk", + "tableFrom": "wallet_transactions", + "tableTo": "payment_transactions", + "columnsFrom": [ + "account_transaction" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owned_by": { + "name": "owned_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "starting_balance": { + "name": "starting_balance", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "wallets_owned_by_users_id_fk": { + "name": "wallets_owned_by_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "owned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.access_tokens": { + "name": "access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "access_tokens_user_users_id_fk": { + "name": "access_tokens_user_users_id_fk", + "tableFrom": "access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_tokens_replaced_by_access_tokens_id_fk": { + "name": "access_tokens_replaced_by_access_tokens_id_fk", + "tableFrom": "access_tokens", + "tableTo": "access_tokens", + "columnsFrom": [ + "replaced_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_connections": { + "name": "account_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "account_connection_providers", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "account_connection_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_connections_user_users_id_fk": { + "name": "account_connections_user_users_id_fk", + "tableFrom": "account_connections", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federated_credentials": { + "name": "federated_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "last_access_token": { + "name": "last_access_token", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_tokens_token_user_index": { + "name": "refresh_tokens_token_user_index", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_users_id_fk": { + "name": "refresh_tokens_user_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refresh_tokens_replaced_by_refresh_tokens_id_fk": { + "name": "refresh_tokens_replaced_by_refresh_tokens_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "refresh_tokens", + "columnsFrom": [ + "replaced_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_tokens_revoked_by_users_id_fk": { + "name": "refresh_tokens_revoked_by_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "revoked_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "refresh_tokens_access_token_access_tokens_id_fk": { + "name": "refresh_tokens_access_token_access_tokens_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "access_tokens", + "columnsFrom": [ + "access_token" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "refresh_tokens_token_unique": { + "name": "refresh_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_prefs": { + "name": "user_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "theme_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'light'" + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_prefs_user_users_id_fk": { + "name": "user_prefs_user_users_id_fk", + "tableFrom": "user_prefs", + "tableTo": "users", + "columnsFrom": [ + "user" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "users_id_seq", + "schema": "public", + "increment": "1", + "startWith": "100", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "names": { + "name": "names", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "dob": { + "name": "dob", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "credentials": { + "name": "credentials", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_credentials_federated_credentials_id_fk": { + "name": "users_credentials_federated_credentials_id_fk", + "tableFrom": "users", + "tableTo": "federated_credentials", + "columnsFrom": [ + "credentials" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_codes": { + "name": "verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "window": { + "name": "window", + "type": "interval", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verification_codes_hash_unique": { + "name": "verification_codes_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.payment_method_provider": { + "name": "payment_method_provider", + "schema": "public", + "values": [ + "momo" + ] + }, + "public.payment_method_status": { + "name": "payment_method_status", + "schema": "public", + "values": [ + "active", + "inactive", + "re-connection required" + ] + }, + "public.transaction_status": { + "name": "transaction_status", + "schema": "public", + "values": [ + "pending", + "cancelled", + "complete" + ] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": [ + "funding", + "reward", + "withdrawal" + ] + }, + "public.account_connection_providers": { + "name": "account_connection_providers", + "schema": "public", + "values": [ + "telegram" + ] + }, + "public.account_connection_status": { + "name": "account_connection_status", + "schema": "public", + "values": [ + "active", + "inactive", + "reconnect_required" + ] + }, + "public.theme_pref": { + "name": "theme_pref", + "schema": "public", + "values": [ + "system", + "dark", + "light" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.vw_funding_balances": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownedBy": { + "name": "ownedBy", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"wallets\".\"id\", \"wallets\".\"starting_balance\" + SUM(\n CASE\n WHEN (\"wallet_transactions\".\"from\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'funding' and \"wallet_transactions\".\"status\" = 'complete') THEN -\"wallet_transactions\".\"value\"\n WHEN (\"wallet_transactions\".\"to\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'funding' and \"wallet_transactions\".\"status\" = 'complete') THEN \"wallet_transactions\".\"value\"\n ELSE 0\n END\n )::BIGINT as \"balance\", \"wallets\".\"owned_by\" from \"wallets\" left join \"wallet_transactions\" on (\"wallets\".\"id\" = \"wallet_transactions\".\"from\" or \"wallets\".\"id\" = \"wallet_transactions\".\"to\") left join \"users\" on \"wallets\".\"owned_by\" = \"users\".\"id\" group by \"wallets\".\"id\", \"wallets\".\"owned_by\"", + "name": "vw_funding_balances", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_reward_balances": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownedBy": { + "name": "ownedBy", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "definition": "select \"wallets\".\"id\", \"wallets\".\"starting_balance\" + SUM(\n CASE\n WHEN (\"wallet_transactions\".\"from\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'reward' and \"wallet_transactions\".\"status\" = 'complete') THEN -\"wallet_transactions\".\"value\"\n WHEN (\"wallet_transactions\".\"to\" = \"wallets\".\"id\" and \"wallet_transactions\".\"type\" = 'reward' and \"wallet_transactions\".\"status\" = 'complete') THEN \"wallet_transactions\".\"value\"\n ELSE 0\n END\n )::BIGINT as \"balance\", \"wallets\".\"owned_by\" from \"wallets\" left join \"wallet_transactions\" on (\"wallets\".\"id\" = \"wallet_transactions\".\"from\" or \"wallets\".\"id\" = \"wallet_transactions\".\"to\") left join \"users\" on \"wallets\".\"owned_by\" = \"users\".\"id\" group by \"wallets\".\"id\", \"wallets\".\"owned_by\"", + "name": "vw_reward_balances", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_verification_codes": { + "columns": { + "hash": { + "name": "hash", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"hash\", \"created_at\", \n (\"created_at\" + \"window\")::TIMESTAMP\n as \"expires_at\", \n (CASE\n WHEN \"confirmed_at\" IS NOT NULL THEN true\n ELSE NOW() > (\"created_at\" + \"window\")\n END)::BOOlEAN\n as \"is_expired\", \"data\" from \"verification_codes\"", + "name": "vw_verification_codes", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_access_tokens": { + "columns": { + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + } + }, + "definition": "select \"user\", (now() > (\"created_at\" + \"window\")::TIMESTAMP)::BOOLEAN OR revoked_at IS NOT NULL OR replaced_by IS NOT NULL as \"is_expired\", (created_at + \"window\")::TIMESTAMP as \"expires_at\", \"created_at\", \"ip\", \"id\" from \"access_tokens\"", + "name": "vw_access_tokens", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.vw_refresh_tokens": { + "columns": { + "revoked_by": { + "name": "revoked_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "replaced_by": { + "name": "replaced_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "access_token": { + "name": "access_token", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(39)", + "primaryKey": false, + "notNull": true + }, + "user": { + "name": "user", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + } + }, + "definition": "select (now()::TIMESTAMP > (\"created_at\" + \"window\")::TIMESTAMP)::BOOLEAN OR \"revoked_by\" IS NOT NULL as \"is_expired\", (\"created_at\" + \"window\")::TIMESTAMP as \"expires\", \"revoked_by\", \"replaced_by\", \"created_at\", \"access_token\", \"ip\", \"user\", \"token\", \"id\" from \"refresh_tokens\"", + "name": "vw_refresh_tokens", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index af39077..74a7982 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1737024383091, "tag": "0027_freezing_flatman", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1737168318564, + "tag": "0028_odd_mephisto", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/schema/campaigns.ts b/db/schema/campaigns.ts index ee951b9..d3ebadb 100644 --- a/db/schema/campaigns.ts +++ b/db/schema/campaigns.ts @@ -1,7 +1,8 @@ import { sql } from 'drizzle-orm'; import { bigint, check, date, integer, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core'; -import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-zod'; import { users } from './users'; +import { z } from 'zod'; export const campaigns = pgTable('campaigns', { id: bigint({ mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(), @@ -32,18 +33,38 @@ export const newCampaignSchema = createInsertSchema(campaigns).pick({ createdBy: true }); +export const updateCampaignSchema = createUpdateSchema(campaigns).pick({ + title: true, + categories: true, + description: true, + emails: true, + links: true, + phones: true, + media: true, + redirectUrl: true +}).extend({ + title: z.string().optional(), + categories: z.array(z.number()).optional(), + description: z.string().optional(), + emails: z.array(z.string()).optional(), + links: z.array(z.string().url()).optional(), + phones: z.array(z.string()).optional(), + media: z.array(z.string()).optional(), + redirectUrl: z.string().url().optional() +}); + export const campaignPublications = pgTable('campaign_publications', { id: bigint({ mode: 'number' }).generatedAlwaysAsIdentity().primaryKey(), createdAt: timestamp({ mode: 'date' }).defaultNow(), updatedAt: timestamp({ mode: 'date' }).defaultNow(), campaign: bigint({ mode: 'number' }).notNull().references(() => campaigns.id), - tokens: integer().notNull(), + credits: integer().notNull(), publishAfter: date({ mode: 'string' }).defaultNow(), publishBefore: date({ mode: 'string' }) }, table => [{ - checkConstraint: check('tokens_check', sql`${table.tokens} + checkConstraint: check('tokens_check', sql`${table.credits} > 0`) }]); -export const newPublicationSchema = createInsertSchema(campaignPublications).refine(data => data.tokens > 0); +export const newPublicationSchema = createInsertSchema(campaignPublications).refine(data => data.credits > 0); diff --git a/netlify.toml b/netlify.toml index 3c35122..2c99c4f 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,5 +1,6 @@ [functions] node_bundler = "esbuild" +external_node_modules = ["fluent-ffmpeg"] directory = "server/functions" [[redirects]] @@ -18,3 +19,6 @@ status = 200 from = "/*" to = "/index.html" status = 200 + +[context.dev.environment] + VIDEO_PREVIEW_DURATION=10 diff --git a/package.json b/package.json index 62b3002..170df88 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ "drizzle-zod": "^0.6.1", "express": "^4.21.2", "express-http-context": "^1.2.4", + "fluent-ffmpeg": "^2.1.3", "google-libphonenumber": "^3.2.40", + "jimp": "^1.6.0", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", @@ -73,6 +75,7 @@ "@types/body-parser": "^1.19.5", "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", + "@types/fluent-ffmpeg": "^2.1.27", "@types/google-libphonenumber": "^7.4.30", "@types/jasmine": "~5.1.0", "@types/jsonwebtoken": "^9.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9a573a..f91aae4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,9 +86,15 @@ importers: express-http-context: specifier: ^1.2.4 version: 1.2.4 + fluent-ffmpeg: + specifier: ^2.1.3 + version: 2.1.3 google-libphonenumber: specifier: ^3.2.40 version: 3.2.40 + jimp: + specifier: ^1.6.0 + version: 1.6.0 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -165,6 +171,9 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.0 + '@types/fluent-ffmpeg': + specifier: ^2.1.27 + version: 2.1.27 '@types/google-libphonenumber': specifier: ^7.4.30 version: 7.4.30 @@ -1780,6 +1789,118 @@ packages: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jimp/core@1.6.0': + resolution: {integrity: sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w==} + engines: {node: '>=18'} + + '@jimp/diff@1.6.0': + resolution: {integrity: sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw==} + engines: {node: '>=18'} + + '@jimp/file-ops@1.6.0': + resolution: {integrity: sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ==} + engines: {node: '>=18'} + + '@jimp/js-bmp@1.6.0': + resolution: {integrity: sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw==} + engines: {node: '>=18'} + + '@jimp/js-gif@1.6.0': + resolution: {integrity: sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g==} + engines: {node: '>=18'} + + '@jimp/js-jpeg@1.6.0': + resolution: {integrity: sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA==} + engines: {node: '>=18'} + + '@jimp/js-png@1.6.0': + resolution: {integrity: sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg==} + engines: {node: '>=18'} + + '@jimp/js-tiff@1.6.0': + resolution: {integrity: sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw==} + engines: {node: '>=18'} + + '@jimp/plugin-blit@1.6.0': + resolution: {integrity: sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA==} + engines: {node: '>=18'} + + '@jimp/plugin-blur@1.6.0': + resolution: {integrity: sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw==} + engines: {node: '>=18'} + + '@jimp/plugin-circle@1.6.0': + resolution: {integrity: sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw==} + engines: {node: '>=18'} + + '@jimp/plugin-color@1.6.0': + resolution: {integrity: sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA==} + engines: {node: '>=18'} + + '@jimp/plugin-contain@1.6.0': + resolution: {integrity: sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ==} + engines: {node: '>=18'} + + '@jimp/plugin-cover@1.6.0': + resolution: {integrity: sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA==} + engines: {node: '>=18'} + + '@jimp/plugin-crop@1.6.0': + resolution: {integrity: sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang==} + engines: {node: '>=18'} + + '@jimp/plugin-displace@1.6.0': + resolution: {integrity: sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q==} + engines: {node: '>=18'} + + '@jimp/plugin-dither@1.6.0': + resolution: {integrity: sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ==} + engines: {node: '>=18'} + + '@jimp/plugin-fisheye@1.6.0': + resolution: {integrity: sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA==} + engines: {node: '>=18'} + + '@jimp/plugin-flip@1.6.0': + resolution: {integrity: sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg==} + engines: {node: '>=18'} + + '@jimp/plugin-hash@1.6.0': + resolution: {integrity: sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q==} + engines: {node: '>=18'} + + '@jimp/plugin-mask@1.6.0': + resolution: {integrity: sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA==} + engines: {node: '>=18'} + + '@jimp/plugin-print@1.6.0': + resolution: {integrity: sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A==} + engines: {node: '>=18'} + + '@jimp/plugin-quantize@1.6.0': + resolution: {integrity: sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg==} + engines: {node: '>=18'} + + '@jimp/plugin-resize@1.6.0': + resolution: {integrity: sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==} + engines: {node: '>=18'} + + '@jimp/plugin-rotate@1.6.0': + resolution: {integrity: sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw==} + engines: {node: '>=18'} + + '@jimp/plugin-threshold@1.6.0': + resolution: {integrity: sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w==} + engines: {node: '>=18'} + + '@jimp/types@1.6.0': + resolution: {integrity: sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg==} + engines: {node: '>=18'} + + '@jimp/utils@1.6.0': + resolution: {integrity: sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==} + engines: {node: '>=18'} + '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -2420,6 +2541,9 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@ts-morph/common@0.23.0': resolution: {integrity: sha512-m7Lllj9n/S6sOkCkRftpM7L24uvmfXQFedlW/4hENcuJH1HHm9u5EgxZb9uVjQSCGrbBWBkOGgcTxNg36r6ywA==} @@ -2496,6 +2620,9 @@ packages: '@types/express@5.0.0': resolution: {integrity: sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==} + '@types/fluent-ffmpeg@2.1.27': + resolution: {integrity: sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==} + '@types/google-libphonenumber@7.4.30': resolution: {integrity: sha512-Td1X1ayRxePEm6/jPHUBs2tT6TzW1lrVB6ZX7ViPGellyzO/0xMNi+wx5nH6jEitjznq276VGIqjK5qAju0XVw==} @@ -2535,6 +2662,9 @@ packages: '@types/node-forge@1.3.11': resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==} + '@types/node@16.9.1': + resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} + '@types/node@22.10.2': resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==} @@ -2771,6 +2901,9 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + any-base@1.1.0: + resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2804,6 +2937,9 @@ packages: resolution: {integrity: sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg==} engines: {node: ^4.7 || >=6.9 || >=7.3} + async@0.2.10: + resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -2817,6 +2953,10 @@ packages: peerDependencies: postcss: ^8.1.0 + await-to-js@3.0.0: + resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} + engines: {node: '>=6.0.0'} + axios@1.7.9: resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==} @@ -2872,6 +3012,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bmp-ts@1.0.9: + resolution: {integrity: sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==} + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -3724,6 +3867,9 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + exif-parser@0.1.12: + resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==} + exponential-backoff@3.1.1: resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==} @@ -3777,6 +3923,10 @@ packages: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} + file-type@16.5.4: + resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} + engines: {node: '>=10'} + filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} @@ -3823,6 +3973,10 @@ packages: flatted@3.3.2: resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + fluent-ffmpeg@2.1.3: + resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==} + engines: {node: '>=18'} + fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -3911,6 +4065,9 @@ packages: get-tsconfig@4.8.1: resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} + gifwrap@0.10.1: + resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} + git-raw-commits@2.0.11: resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} engines: {node: '>=10'} @@ -4107,6 +4264,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + image-q@4.0.0: + resolution: {integrity: sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==} + image-size@0.5.5: resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} engines: {node: '>=0.10.0'} @@ -4332,10 +4492,17 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jimp@1.6.0: + resolution: {integrity: sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==} + engines: {node: '>=18'} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4685,6 +4852,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -4995,6 +5167,9 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + omggif@1.0.10: + resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -5108,10 +5283,22 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-bmfont-ascii@1.0.6: + resolution: {integrity: sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==} + + parse-bmfont-binary@1.0.6: + resolution: {integrity: sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==} + + parse-bmfont-xml@1.1.6: + resolution: {integrity: sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==} + parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} @@ -5200,6 +5387,10 @@ packages: pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + peek-readable@4.1.0: + resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} + engines: {node: '>=8'} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -5248,10 +5439,22 @@ packages: piscina@4.7.0: resolution: {integrity: sha512-b8hvkpp9zS0zsfa939b/jXbe64Z2gZv0Ha7FYPNUiDIB1y2AtxcOZdfP8xN8HFjUaqQiT9gRlfjAsoL8vdJ1Iw==} + pixelmatch@5.3.0: + resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} + hasBin: true + pkg-dir@7.0.0: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} + pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -5485,6 +5688,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-web-to-node-stream@3.0.2: + resolution: {integrity: sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==} + engines: {node: '>=8'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -5767,6 +5974,10 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + simple-xml-to-json@1.2.3: + resolution: {integrity: sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA==} + engines: {node: '>=20.12.2'} + slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} @@ -5922,6 +6133,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strtok3@6.3.0: + resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} + engines: {node: '>=10'} + sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'} @@ -6026,6 +6241,9 @@ packages: thunky@1.1.0: resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -6042,6 +6260,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@4.2.1: + resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} + engines: {node: '>=10'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -6197,6 +6419,9 @@ packages: urlpattern-polyfill@8.0.2: resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + utif2@4.1.0: + resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -6405,6 +6630,17 @@ packages: utf-8-validate: optional: true + xml-parse-from-string@1.0.1: + resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==} + + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -7945,6 +8181,195 @@ snapshots: dependencies: '@sinclair/typebox': 0.27.8 + '@jimp/core@1.6.0': + dependencies: + '@jimp/file-ops': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + await-to-js: 3.0.0 + exif-parser: 0.1.12 + file-type: 16.5.4 + mime: 3.0.0 + + '@jimp/diff@1.6.0': + dependencies: + '@jimp/plugin-resize': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + pixelmatch: 5.3.0 + + '@jimp/file-ops@1.6.0': {} + + '@jimp/js-bmp@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + bmp-ts: 1.0.9 + + '@jimp/js-gif@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + gifwrap: 0.10.1 + omggif: 1.0.10 + + '@jimp/js-jpeg@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + jpeg-js: 0.4.4 + + '@jimp/js-png@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + pngjs: 7.0.0 + + '@jimp/js-tiff@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + utif2: 4.1.0 + + '@jimp/plugin-blit@1.6.0': + dependencies: + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-blur@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/utils': 1.6.0 + + '@jimp/plugin-circle@1.6.0': + dependencies: + '@jimp/types': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-color@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + tinycolor2: 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-contain@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/plugin-blit': 1.6.0 + '@jimp/plugin-resize': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-cover@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/plugin-crop': 1.6.0 + '@jimp/plugin-resize': 1.6.0 + '@jimp/types': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-crop@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-displace@1.6.0': + dependencies: + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-dither@1.6.0': + dependencies: + '@jimp/types': 1.6.0 + + '@jimp/plugin-fisheye@1.6.0': + dependencies: + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-flip@1.6.0': + dependencies: + '@jimp/types': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-hash@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/js-bmp': 1.6.0 + '@jimp/js-jpeg': 1.6.0 + '@jimp/js-png': 1.6.0 + '@jimp/js-tiff': 1.6.0 + '@jimp/plugin-color': 1.6.0 + '@jimp/plugin-resize': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + any-base: 1.1.0 + + '@jimp/plugin-mask@1.6.0': + dependencies: + '@jimp/types': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-print@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/js-jpeg': 1.6.0 + '@jimp/js-png': 1.6.0 + '@jimp/plugin-blit': 1.6.0 + '@jimp/types': 1.6.0 + parse-bmfont-ascii: 1.0.6 + parse-bmfont-binary: 1.0.6 + parse-bmfont-xml: 1.1.6 + simple-xml-to-json: 1.2.3 + zod: 3.24.1 + + '@jimp/plugin-quantize@1.6.0': + dependencies: + image-q: 4.0.0 + zod: 3.24.1 + + '@jimp/plugin-resize@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-rotate@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/plugin-crop': 1.6.0 + '@jimp/plugin-resize': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + zod: 3.24.1 + + '@jimp/plugin-threshold@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/plugin-color': 1.6.0 + '@jimp/plugin-hash': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + zod: 3.24.1 + + '@jimp/types@1.6.0': + dependencies: + zod: 3.24.1 + + '@jimp/utils@1.6.0': + dependencies: + '@jimp/types': 1.6.0 + tinycolor2: 1.6.0 + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -8496,6 +8921,8 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@tokenizer/token@0.3.0': {} + '@ts-morph/common@0.23.0': dependencies: fast-glob: 3.3.2 @@ -8605,6 +9032,10 @@ snapshots: '@types/qs': 6.9.17 '@types/serve-static': 1.15.7 + '@types/fluent-ffmpeg@2.1.27': + dependencies: + '@types/node': 22.10.2 + '@types/google-libphonenumber@7.4.30': {} '@types/http-cache-semantics@4.0.4': {} @@ -8641,6 +9072,8 @@ snapshots: dependencies: '@types/node': 22.10.2 + '@types/node@16.9.1': {} + '@types/node@22.10.2': dependencies: undici-types: 6.20.0 @@ -8907,6 +9340,8 @@ snapshots: ansi-styles@6.2.1: {} + any-base@1.1.0: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -8935,6 +9370,8 @@ snapshots: dependencies: stack-chain: 1.3.7 + async@0.2.10: {} + async@3.2.6: {} asynckit@0.4.0: {} @@ -8949,6 +9386,8 @@ snapshots: postcss: 8.4.49 postcss-value-parser: 4.2.0 + await-to-js@3.0.0: {} + axios@1.7.9: dependencies: follow-redirects: 1.15.9(debug@4.4.0) @@ -9019,6 +9458,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bmp-ts@1.0.9: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -9927,6 +10368,8 @@ snapshots: events@3.3.0: {} + exif-parser@0.1.12: {} + exponential-backoff@3.1.1: {} express-http-context@1.2.4: @@ -10021,6 +10464,12 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + file-type@16.5.4: + dependencies: + readable-web-to-node-stream: 3.0.2 + strtok3: 6.3.0 + token-types: 4.2.1 + filelist@1.0.4: dependencies: minimatch: 5.1.6 @@ -10085,6 +10534,11 @@ snapshots: flatted@3.3.2: {} + fluent-ffmpeg@2.1.3: + dependencies: + async: 0.2.10 + which: 1.3.1 + fn.name@1.1.0: {} follow-redirects@1.15.9(debug@4.4.0): @@ -10169,6 +10623,11 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gifwrap@0.10.1: + dependencies: + image-q: 4.0.0 + omggif: 1.0.10 + git-raw-commits@2.0.11: dependencies: dargs: 7.0.0 @@ -10408,6 +10867,10 @@ snapshots: ignore@5.3.2: {} + image-q@4.0.0: + dependencies: + '@types/node': 16.9.1 + image-size@0.5.5: optional: true @@ -10604,8 +11067,40 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jimp@1.6.0: + dependencies: + '@jimp/core': 1.6.0 + '@jimp/diff': 1.6.0 + '@jimp/js-bmp': 1.6.0 + '@jimp/js-gif': 1.6.0 + '@jimp/js-jpeg': 1.6.0 + '@jimp/js-png': 1.6.0 + '@jimp/js-tiff': 1.6.0 + '@jimp/plugin-blit': 1.6.0 + '@jimp/plugin-blur': 1.6.0 + '@jimp/plugin-circle': 1.6.0 + '@jimp/plugin-color': 1.6.0 + '@jimp/plugin-contain': 1.6.0 + '@jimp/plugin-cover': 1.6.0 + '@jimp/plugin-crop': 1.6.0 + '@jimp/plugin-displace': 1.6.0 + '@jimp/plugin-dither': 1.6.0 + '@jimp/plugin-fisheye': 1.6.0 + '@jimp/plugin-flip': 1.6.0 + '@jimp/plugin-hash': 1.6.0 + '@jimp/plugin-mask': 1.6.0 + '@jimp/plugin-print': 1.6.0 + '@jimp/plugin-quantize': 1.6.0 + '@jimp/plugin-resize': 1.6.0 + '@jimp/plugin-rotate': 1.6.0 + '@jimp/plugin-threshold': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + jiti@1.21.7: {} + jpeg-js@0.4.4: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -11004,6 +11499,8 @@ snapshots: mime@2.6.0: {} + mime@3.0.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -11356,6 +11853,8 @@ snapshots: obuf@1.1.2: {} + omggif@1.0.10: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -11498,10 +11997,21 @@ snapshots: - bluebird - supports-color + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-bmfont-ascii@1.0.6: {} + + parse-bmfont-binary@1.0.6: {} + + parse-bmfont-xml@1.1.6: + dependencies: + xml-parse-from-string: 1.0.1 + xml2js: 0.5.0 + parse-json@4.0.0: dependencies: error-ex: 1.3.2 @@ -11586,6 +12096,8 @@ snapshots: pause@0.0.1: {} + peek-readable@4.1.0: {} + pend@1.2.0: {} pg-int8@1.0.1: {} @@ -11623,10 +12135,18 @@ snapshots: optionalDependencies: '@napi-rs/nice': 1.0.1 + pixelmatch@5.3.0: + dependencies: + pngjs: 6.0.0 + pkg-dir@7.0.0: dependencies: find-up: 6.3.0 + pngjs@6.0.0: {} + + pngjs@7.0.0: {} + postcss-import@15.1.0(postcss@8.4.49): dependencies: postcss: 8.4.49 @@ -11846,6 +12366,10 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-web-to-node-stream@3.0.2: + dependencies: + readable-stream: 3.6.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -12015,8 +12539,7 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.0 - sax@1.4.1: - optional: true + sax@1.4.1: {} schema-utils@3.3.0: dependencies: @@ -12158,6 +12681,8 @@ snapshots: dependencies: is-arrayish: 0.3.2 + simple-xml-to-json@1.2.3: {} + slash@5.1.0: {} slice-ansi@5.0.0: @@ -12364,6 +12889,11 @@ snapshots: dependencies: min-indent: 1.0.1 + strtok3@6.3.0: + dependencies: + '@tokenizer/token': 0.3.0 + peek-readable: 4.1.0 + sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.8 @@ -12496,6 +13026,8 @@ snapshots: thunky@1.1.0: {} + tinycolor2@1.6.0: {} + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -12508,6 +13040,11 @@ snapshots: toidentifier@1.0.1: {} + token-types@4.2.1: + dependencies: + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tr46@0.0.3: {} tree-dump@1.0.2(tslib@2.8.1): @@ -12636,6 +13173,10 @@ snapshots: urlpattern-polyfill@8.0.2: {} + utif2@4.1.0: + dependencies: + pako: 1.0.11 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -12857,6 +13398,15 @@ snapshots: ws@8.18.0: {} + xml-parse-from-string@1.0.1: {} + + xml2js@0.5.0: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/server/functions/blob/index.mts b/server/functions/blob/index.mts index 28064b9..8a3e371 100644 --- a/server/functions/blob/index.mts +++ b/server/functions/blob/index.mts @@ -1,4 +1,4 @@ -import { Context } from '@netlify/functions'; +import { Config, Context } from '@netlify/functions'; import { handleUploads, serveUploadedFile } from '@handlers/blob.mjs'; import { rawAuth } from '@middleware/auth.mjs'; @@ -10,4 +10,8 @@ export default async function (req: Request, ctx: Context) { return new Response(null, { status: 404 }); } - +export const config: Config = { + path: [ + '/api/blob/upload', '/api/blob/:key' + ] +} diff --git a/server/functions/campaigns/index.mts b/server/functions/campaigns/index.mts index 3378505..2d5d92e 100644 --- a/server/functions/campaigns/index.mts +++ b/server/functions/campaigns/index.mts @@ -1,9 +1,11 @@ import { createCampaign, createCampaignPublication, + deleteCampaign, findCampaignPublications, findUserCampaign, - lookupUserCampaings + lookupUserCampaings, + updateCampaignInfo } from '@handlers/campaign.mjs'; import { prepareHandler } from '@helpers/handler.mjs'; import { jwtAuth } from '@middleware/auth.mjs'; @@ -17,5 +19,7 @@ router.post('/', createCampaign); router.get('/:campaign/publications', findCampaignPublications); router.post('/:campaign/publications', createCampaignPublication); router.get('/:campaign', findUserCampaign); +router.patch('/:campaign', updateCampaignInfo); +router.delete('/:campaign', deleteCampaign); export const handler = prepareHandler('campaigns', router); diff --git a/server/handlers/blob.mts b/server/handlers/blob.mts index 55230ef..8ad5820 100644 --- a/server/handlers/blob.mts +++ b/server/handlers/blob.mts @@ -1,64 +1,137 @@ import { useLogger } from "@logger/common"; -import { getStore } from "@netlify/blobs"; +import { getStore, Store } from "@netlify/blobs"; import { Context } from "@netlify/functions"; -import { createHash } from "node:crypto"; +import ffmpeg from 'fluent-ffmpeg'; +import { Jimp } from 'jimp'; +import { createHash, randomUUID } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Writable } from 'node:stream'; const logger = useLogger({ service: 'blobs' }); -export async function serveUploadedFile(_: Request, ctx: Context) { - const key = ctx.url.pathname.split('/')[3]?.split('.')[0]; + +export async function serveUploadedFile(event: Request, ctx: Context) { + // const key = ctx.params['key'].substring(0, ctx.params['key'].lastIndexOf('.')); + const [key] = ctx.url.pathname.split('/')[3]?.split('.'); + // const isPreviewRequested = !url.searchParams.has('preview') || url.searchParams.get('preview') != null || url.searchParams.get('preview') == 'true'; + logger.info('serving blob file', { key }); if (!key) { return new Response(null, { status: 404 }); } - const uploadStore = getStore({ - name: 'userUploads', consistency: 'strong' + + // if (!isPreviewRequested) { + return await serveFileFromUploadStore(key); + // } + + // return await serveFileFromPreviewStores(key, extension); +} + +async function serveFileFromPreviewStores(key: string, extension: string) { + const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp", ".tiff", ".tif"]; + logger.debug('serving file from preview stores', { key }); + + let store: Store + if (imageExtensions.includes(extension)) { + logger.debug('using thumbnail store', { key }); + store = getStore({ + name: 'thumbnails', consistency: 'strong' + }); + } else { + logger.debug('using previews store', { key }); + store = getStore({ + name: 'previews', consistency: 'strong' + }); + } + + return await getFileFromStore(store, key); +} + +async function serveFileFromUploadStore(key: string) { + logger.info('serving file from upload store', { key }); + + const store = getStore({ + name: 'uploads', consistency: 'strong' }); - const result = await uploadStore.getWithMetadata(key, { type: 'stream' }); + return await getFileFromStore(store, key); +} + +async function getFileFromStore(store: Store, key: string) { + const result = await store.getWithMetadata(key, { type: 'stream' }); if (!result) return new Response(null, { status: 404 }); const { data, metadata } = result; - return new Response(data, { headers: { 'content-type': metadata['mimeType'] as string } }); + return new Response(data, { headers: { 'content-type': metadata['mimetype'] as string } }); } export async function handleUploads(req: Request, ctx: Context) { logger.info('handling file upload'); + const urls = Array(); const data = await req.formData(); const entries = data.getAll('uploads'); if (entries.length <= 0) { return new Response(JSON.stringify({ message: 'Empty uploads' }), { - status: 404, + status: 400, headers: { 'content-type': 'application/json' } }); } const uploadStore = getStore({ - name: 'userUploads', + name: 'uploads', consistency: 'strong' }); + // const thumbnailStore = getStore({ + // name: 'thumbnails', + // consistency: 'strong' + // }); + + // const previewStore = getStore({ + // name: 'previews', + // consistency: 'strong' + // }); + for await (const entry of entries) { const file = entry as File; - const cipher = createHash('md5'); const [r1, r2] = file.stream().tee(); const buff = await streamToBuffer(r2); - for await (const chunk of r1) { - cipher.update(chunk); - } + const extension = file.name.substring(file.name.lastIndexOf('.')); + const fileKey = await computeBlobStoreKey(r1); - const key = cipher.digest().toString('hex'); + const storeMetadata = { + mimeType: file.type, + originalName: file.name, + size: file.size + } as Record; - const extension = file.name.split('.')[1]; - await uploadStore.set(key, buff, { - metadata: { - mimeType: file.type, - originalName: file.name, - size: file.size - } - }); - const url = `${ctx.url.origin}/api/blob/${key}.${extension}`; + // if (file.type.startsWith('image')) { + // const thumbnail = await generateThumbnail(buff, extension); + + // await thumbnailStore.set(fileKey, thumbnail, { + // metadata: { + // ...storeMetadata, + // size: thumbnail.byteLength + // } as Record + // }); + // } + + // if (file.type.startsWith('video')) { + // const preview = await generatePreviewVideo(buff); + + // await previewStore.set(fileKey, preview, { + // metadata: { + // ...storeMetadata, + // size: preview.byteLength + // } + // }); + // } + + await uploadStore.set(fileKey, buff, { metadata: storeMetadata }); + const url = `${ctx.url.origin}/api/blob/${fileKey}${extension}`; urls.push(url); } @@ -79,3 +152,95 @@ async function streamToBuffer(stream: ReadableStream) { reader.releaseLock(); } } + +async function generateThumbnail(buffer: Buffer, ext: string) { + const j = await Jimp.read(buffer); + const tmp = join(tmpdir(), randomUUID()); + j.resize({ w: 150, h: 150 }); + await j.write(`${tmp}.${ext}`); + + return await readFile(`${tmp}.${ext}`) +} + +async function computeBlobStoreKey(input: ReadableStream> | Buffer) { + const cipher = createHash('md5'); + if (Buffer.isBuffer(input)) { + cipher.update(input); + } else { + for await (const chunk of input) { + cipher.update(chunk); + } + } + return cipher.digest('hex'); +} + +async function generatePreviewVideo(video: ReadableStream> | Buffer) { + logger.info('generating video preview'); + const tmp = join(tmpdir(), randomUUID()); + await writeFile(tmp, video) + + const { durationInSeconds, size } = await getVideoInfo(tmp); + const fragmentDuration = Number(process.env['VIDEO_PREVIEW_DURATION'] ?? 4); + const startTimeInSeconds = getStartTimeInSeconds(durationInSeconds, fragmentDuration); + + const output = new BufferStream(); + + await new Promise((resolve, reject) => { + ffmpeg() + .addInput(tmp) + .inputOptions([`-ss ${startTimeInSeconds}`]) + .outputOptions([`-t ${fragmentDuration}`]) + .noAudio() + .output(output) + .on('end', resolve) + .on('error', reject) + .run(); + }); + + return output.getBuffer(); +} + +function getStartTimeInSeconds(totalDuration: number, fragmentDuration: number) { + const safeDuration = Math.max(totalDuration - fragmentDuration, 0); + + if (safeDuration == 0) return 0; + + const min = Math.ceil(.25 * safeDuration); + const max = Math.floor(.75 * safeDuration); + + return Math.floor(Math.random() * (max - min + 1) + min); +} + +function getVideoInfo(path: string) { + return new Promise<{ durationInSeconds: number, size: number }>((resolve, reject) => { + return ffmpeg.ffprobe(path, (err, info) => { + if (err) { + return reject(err); + } + + const { duration, size } = info.format; + + if (duration === undefined || size === undefined) { + return reject(new Error('invalid size or duration')); + } + + return resolve({ + size, + durationInSeconds: Math.floor(duration) + }) + }) + }); +} + +class BufferStream extends Writable { + private buffer = Array(); + + override _write(chunk: Uint8Array, encoding: string, callback: () => void) { + this.buffer.push(chunk); + callback(); + } + + getBuffer() { + return Buffer.concat(this.buffer); + } +} diff --git a/server/handlers/campaign.mts b/server/handlers/campaign.mts index 10548f5..9b9d9b9 100644 --- a/server/handlers/campaign.mts +++ b/server/handlers/campaign.mts @@ -3,17 +3,89 @@ import { useCampaignsDb } from '@helpers/db.mjs'; import { handleError } from '@helpers/error.mjs'; import { LookupCampaignResponse } from '@lib/models/campaign'; import { useLogger } from '@logger/common'; -import { CampaignLookupSchema, campaignPublications, campaigns, newCampaignSchema, newPublicationSchema } from '@schemas/campaigns'; -import { count, eq } from 'drizzle-orm'; +import { CampaignLookupSchema, campaignPublications, campaigns, newCampaignSchema, newPublicationSchema, updateCampaignSchema } from '@schemas/campaigns'; +import { CampaignIdExtractorSchema, CampaignLookupPaginationValidationSchema } from '@zod-schemas/campaigns.mjs'; +import { and, count, eq } from 'drizzle-orm'; import express from 'express'; import { z } from 'zod'; import { fromZodError } from 'zod-validation-error'; const logger = useLogger({ service: 'campaign' }); +const IdExtractorSchema = CampaignIdExtractorSchema('campaign'); + +export async function deleteCampaign(req: express.Request, res: express.Response) { + const { success: idValid, data: idInfo } = IdExtractorSchema.safeParse(req.params); + if (!idValid) { + logger.warn('id validation failed for campaign delete', { params: req.params }); + res.status(400).json({ message: 'Invalid campaign ID' }); + return; + } + + logger.info('deleting campaign'); + const { campaign } = idInfo; + + try { + const user = extractUser(req); + const db = useCampaignsDb(); + await db.transaction(async t => { + await t.delete(campaignPublications).where(eq(campaignPublications.campaign, campaign)); + await t.delete(campaigns).where(and(eq(campaigns.id, campaign), eq(campaigns.createdBy, user.id))); + }); + res.status(200).json({}); + } catch (e) { + handleError(e as Error, res); + } +} + +export async function updateCampaignInfo(req: express.Request, res: express.Response) { + const { success: idValid, data: idInfo } = IdExtractorSchema.safeParse(req.params); + if (!idValid) { + logger.warn('id validation failed for campaign update', { params: req.params }); + res.status(400).json({ message: 'Invalid campaign ID' }); + return; + } + + logger.info('updating campaign'); + const { campaign } = idInfo; + + const { success: bodyValid, data: updateData, error: validationError } = updateCampaignSchema.safeParse(req.body); + + if (!bodyValid) { + logger.warn('body validation failed for campaign update', { data: req.body }); + res.status(400).json({ message: fromZodError(validationError) }); + return; + } + + const user = extractUser(req); + + try { + logger.info('updating database', { id: campaign }); + const db = useCampaignsDb(); + const { rowCount } = await db.transaction(t => t.update(campaigns).set(updateData).where(and(eq(campaigns.id, campaign), eq(campaigns.createdBy, user.id)))) + + if (rowCount == 0) { + logger.warn('campaign not found while updating', { id: campaign }); + res.status(404).json({ message: 'Campaign not found' }); + } else { + logger.info('campaign updated', { id: campaign }); + res.status(202).json({}); + } + } catch (e) { + return handleError(e as Error, res); + } +} export async function createCampaignPublication(req: express.Request, res: express.Response) { + const { success, data } = IdExtractorSchema.safeParse(req.params); + if (!success) { + res.status(400).json({ message: 'Invalid campaign ID' }); + return; + } + + const { campaign: campaignId } = data; + const db = useCampaignsDb(); - const { campaign: campaignId } = req.params; + logger.info('creating campaign publication', { campaign: campaignId }); const user = extractUser(req); try { @@ -34,7 +106,13 @@ export async function createCampaignPublication(req: express.Request, res: expre } export async function findCampaignPublications(req: express.Request, res: express.Response) { - const { campaign } = req.params; + const { success, data } = IdExtractorSchema.safeParse(req.params); + if (!success) { + res.status(400).json({ message: 'Invalid campaign ID' }); + return; + } + + const { campaign } = data; const db = useCampaignsDb(); try { const publications = await db.query.campaignPublications.findMany({ @@ -68,8 +146,7 @@ export async function createCampaign(req: express.Request, res: express.Response export async function lookupUserCampaings(req: express.Request, res: express.Response) { const db = useCampaignsDb(); - const page = Number(req.query['page'] ?? 0); - const size = Number(req.query['size'] ?? 10); + const { page, size } = CampaignLookupPaginationValidationSchema.parse(req.query); const user = extractUser(req); const data = await db.query.campaigns.findMany({ columns: { @@ -97,14 +174,15 @@ export async function lookupUserCampaings(req: express.Request, res: express.Res } export async function findUserCampaign(req: express.Request, res: express.Response) { - const id = Number(req.params['campaign']); + const { success, data } = IdExtractorSchema.safeParse(req.params); - if (isNaN(id)) { + if (!success) { res.status(400).json({ message: 'Invalid campaign ID' }); return; } - logger.info('reading campaign', { id }); + const { campaign: id } = data; + logger.info('fetching campaign', { id }); const user = extractUser(req); try { const db = useCampaignsDb(); diff --git a/server/middleware/auth.mts b/server/middleware/auth.mts index 29290c9..35eff54 100644 --- a/server/middleware/auth.mts +++ b/server/middleware/auth.mts @@ -65,7 +65,6 @@ export const rawAuth = async (req: Request, ctx: Context, next: (req: Request, c headers: { 'content-type': 'application/json' } }); if (!headerValue) return unauthorizedResponse; - console.log(headerValue); const [scheme, token] = headerValue; @@ -73,7 +72,6 @@ export const rawAuth = async (req: Request, ctx: Context, next: (req: Request, c try { const { success, data, error } = AccessTokenValidationSchema.safeParse(token); if (!success) { - console.log(error); return unauthorizedResponse; } diff --git a/server/schemas/campaigns.mts b/server/schemas/campaigns.mts index e69de29..3397612 100644 --- a/server/schemas/campaigns.mts +++ b/server/schemas/campaigns.mts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const CampaignIdExtractorSchema = (selector: string) => z.object({ + [selector]: z.string().pipe(z.coerce.number()).refine(n => !isNaN(n)) +}); + +export const CampaignLookupPaginationValidationSchema = z.object({ + page: z.string() + .optional() + .transform(n => { + if (n && /^\d+$/.test(n)) return n; + return undefined; + }) + .pipe(z.coerce.number().optional().default(0)), + size: z.string() + .optional() + .transform(n => { + if (n && /^\d+$/.test(n)) return n; + return undefined; + }) + .pipe(z.coerce.number().optional().default(10)) +}) diff --git a/src/app/components/campaign-settings/campaign-settings.component.html b/src/app/components/campaign-settings/campaign-settings.component.html index c9b02d5..a1f2814 100644 --- a/src/app/components/campaign-settings/campaign-settings.component.html +++ b/src/app/components/campaign-settings/campaign-settings.component.html @@ -1,5 +1,5 @@
-
+
@@ -12,10 +12,20 @@
- -
- +
+
+ +
+ @if(generalControls.title.dirty && generalControls.title.invalid) { + + @if(generalControls.title.hasError('required')) { + This field cannot be empty + } + + }
+
@@ -26,13 +36,25 @@
-

{{ campaign()?.description ?? 'Not set' }}

+

{{ campaign()?.description ?? 'Not set' }}

- +
+
+ +
+ @if(generalControls.description.dirty && generalControls.description.invalid) { + + @if(generalControls.description.hasError('required')) { + This field cannot be empty + } + + } +
- +
@@ -47,17 +69,31 @@ @let count = campaign()?.categories?.length ?? 0;

Not set - 1 category - {{count}} categories + {{getCategory(campaign()?.categories![0])?.title ?? + 'Unknown'}} + {{count}} categories 99+ categories

- +
+
+ +
+ @if(generalControls.categories.dirty && generalControls.categories.invalid) { + + @if(generalControls.categories.hasError('required')) { + This field cannot be empty + } + + } +
- +
@@ -73,9 +109,23 @@
- +
+
+ +
+ @if(generalControls.redirectUrl.dirty && generalControls.redirectUrl.invalid) { + + @if(generalControls.redirectUrl.hasError('required')) { + This field cannot be empty + }@else if (generalControls.redirectUrl.hasError('pattern')) { + Invalid URL + } + + } +
- +
@@ -84,9 +134,9 @@
-
+
- + Links @@ -112,28 +162,114 @@ Email addresses - emails +
+ @for(emailControl of emailControls;track $index){ + + } +
- -
+
- +
+
+
+ +
+

Attach images and videos to your campaign to make them stand out.

+
+ @if(attachmentControls.length > 0) { + +
+ @for(control of attachmentControls; track $index) { + @let url = control.value; + @let attachmentType = getAttachmentType(url); +
+ @switch(attachmentType) { + @case('image') { + media_{{$index}} + + } + @case('video') { + + } + } +
+ +
+
+ } +
+ } +
-
+
+

Deleting a campaign will invalidate all active publications and release all remaining allocated credits

-
+
+ + + @if(!message.sticky) { + {{ message.detail }} + } @else { +
+

Save pending changes?

+
+ + +
+
+ } +
+
+ + + +
+
+ + @if(control.invalid && control.dirty) { + + @if(control.hasError('email')) { + Invalid email address + } + + } +
+ @if(length > 0 && index + < length-1) { + } +
+
+
@@ -155,7 +291,7 @@
-
@if(group.invalid && group.dirty) { diff --git a/src/app/components/campaign-settings/campaign-settings.component.ts b/src/app/components/campaign-settings/campaign-settings.component.ts index 5054752..f6395bb 100644 --- a/src/app/components/campaign-settings/campaign-settings.component.ts +++ b/src/app/components/campaign-settings/campaign-settings.component.ts @@ -1,57 +1,96 @@ import { NgPlural, NgPluralCase, NgTemplateOutlet } from '@angular/common'; -import { HttpClient } from '@angular/common/http'; -import { AfterViewInit, Component, inject, input } from '@angular/core'; +import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { Component, effect, inject, input, output, signal, viewChildren } from '@angular/core'; import { rxResource, takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { AbstractControl, FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { PhoneDirective } from '@app/directives/phone.directive'; import { phoneValidator } from '@app/util/phone-valiator'; import { Campaign } from '@lib/models/campaign'; import { Category } from '@lib/models/category'; import { CountryData } from '@lib/models/country-data'; import { PhoneNumberFormat, PhoneNumberUtil } from 'google-libphonenumber'; +import { get, isEqual } from 'lodash'; import { AccordionModule, } from 'primeng/accordion'; +import { ConfirmationService, MessageService, ToastMessageOptions } from 'primeng/api'; import { Button } from 'primeng/button'; +import { ConfirmDialog } from 'primeng/confirmdialog'; +import { Divider } from 'primeng/divider'; import { Fieldset } from 'primeng/fieldset'; +import { FileUpload, FileUploadErrorEvent, FileUploadEvent } from 'primeng/fileupload'; import { Inplace } from 'primeng/inplace'; import { InputText } from 'primeng/inputtext'; import { Message } from 'primeng/message'; import { MultiSelect } from 'primeng/multiselect'; import { Select } from 'primeng/select'; import { Textarea } from 'primeng/textarea'; +import { Toast } from 'primeng/toast'; const phoneUtil = PhoneNumberUtil.getInstance(); +function isImageOrVideoUrl(url: string): "image" | "video" | null { + const lowerCaseUrl = url.toLowerCase(); + + const imageExtensions = [ + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp", ".tiff", ".tif", + ]; + for (const ext of imageExtensions) { + if (lowerCaseUrl.endsWith(ext)) { + return "image"; + } + } + + const videoExtensions = [ + ".mp4", ".mov", ".avi", ".wmv", ".flv", ".webm", ".mkv", ".mpeg", ".mpg", + ]; + for (const ext of videoExtensions) { + if (lowerCaseUrl.endsWith(ext)) { + return "video"; + } + } + + return null; +} + function newAttachmentControl(url: string) { return new FormControl(url, { nonNullable: true }); } -function newEmailControl() { - return new FormControl('', { +function newEmailControl(value = '') { + return new FormControl(value, { nonNullable: true, validators: [Validators.maxLength(100), Validators.email] }); } -function newPhoneControl(defaultCode = 'CM') { +function newPhoneControl(defaultCode = 'CM', number?: string) { return new FormGroup({ code: new FormControl(defaultCode, { nonNullable: true, }), - number: new FormControl('', { nonNullable: false }) + number: new FormControl(number, { nonNullable: false }) }, [phoneValidator()]); } -function newLinkControl() { - return new FormControl('', [Validators.pattern(/^((http|https|ftp):\/\/)?(([\w-]+\.)+[\w-]+)(:\d+)?(\/[\w-]*)*(\?[\w-=&]*)?(#[\w-]*)?$/)]) +function newLinkControl(value = '') { + return new FormControl(value, [Validators.pattern(/^((http|https|ftp):\/\/)?(([\w-]+\.)+[\w-]+)(:\d+)?(\/[\w-]*)*(\?[\w-=&]*)?(#[\w-]*)?$/)]) } @Component({ + providers: [MessageService, ConfirmationService], selector: 'tm-campaign-settings', - imports: [PhoneDirective, Select, NgTemplateOutlet, ReactiveFormsModule, Message, AccordionModule, Fieldset, Textarea, Inplace, InputText, Button, MultiSelect, NgPlural, NgPluralCase], + imports: [Divider, ConfirmDialog, Toast, PhoneDirective, FileUpload, Select, NgTemplateOutlet, ReactiveFormsModule, Message, AccordionModule, Fieldset, Textarea, Inplace, InputText, Button, MultiSelect, NgPlural, NgPluralCase], templateUrl: './campaign-settings.component.html', styleUrl: './campaign-settings.component.scss' }) -export class CampaignSettings implements AfterViewInit { +export class CampaignSettings { + private pendingToastMessageVisible = false; + private inPlaceControls = viewChildren(Inplace); + readonly uploading = signal(false); + readonly updating = signal(false); + readonly deleting = signal(false); private http = inject(HttpClient); + readonly updated = output(); + readonly deleted = output(); + readonly error = output(); readonly campaign = input(); readonly categories = rxResource({ loader: () => this.http.get('/api/categories') @@ -62,84 +101,368 @@ export class CampaignSettings implements AfterViewInit { readonly form = new FormGroup({ general: new FormGroup({ title: new FormControl('', { nonNullable: true, validators: [Validators.required] }), - categories: new FormControl([], { nonNullable: true, validators: [Validators.required] }), - redirectUrl: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.pattern(/^((http|https|ftp):\/\/)?(([\w-]+\.)+[\w-]+)(:\d+)?(\/[\w-]*)*(\?[\w-=&]*)?(#[\w-]*)?$/)] }) + categories: new FormControl([], { nonNullable: true, validators: [] }), + description: new FormControl(null, []), + redirectUrl: new FormControl('', { validators: [Validators.pattern(/^((http|https|ftp):\/\/)?(([\w-]+\.)+[\w-]+)(:\d+)?(\/[\w-]*)*(\?[\w-=&]*)?(#[\w-]*)?$/)] }) }), linksAndContacts: new FormGroup({ links: new FormArray>([newLinkControl()]), - phoneNumbers: new FormArray; - number: FormControl; + number: FormControl; }>>([newPhoneControl()]), emails: new FormArray>([newEmailControl()]) }), - attachments: new FormArray>([]) + media: new FormArray>([]) }); - get basicControls() { + get emails() { + return this.form.controls.linksAndContacts.controls.emails; + } + + get emailControls() { + return this.form.controls.linksAndContacts.controls.emails.controls; + } + + get general() { + return this.form.controls.general; + } + + get modifiedGeneralOutput() { + return Object.entries(this.general.controls).filter(([_, c]) => c.dirty) + .reduce((acc, [k, c]) => { + acc[k] = c.value; + return acc; + }, {} as Record); + } + + get modifiedLinksAndContactsOutput() { + const ans = { + links: this.links.value.slice(0, this.linkControls.length - 1), + emails: this.emails.value.slice(0, this.emails.length - 1), + phones: this.phones.value.slice(0, this.phones.length - 1) + .map(({ code, number }) => phoneUtil.parse(number ?? undefined, code)) + .map(p => phoneUtil.format(p, PhoneNumberFormat.E164)) + } as Record; + + if (ans['links'].length == 0 || isEqual(ans['links'], this.campaign()?.links ?? [])) + delete ans['links']; + + if (ans['emails'].length == 0 || isEqual(ans['emails'], this.campaign()?.emails ?? [])) + delete ans['emails']; + + if (ans['phones'].length == 0 || isEqual(ans['phones'], this.campaign()?.phones ?? [])) + delete ans['phones']; + + return ans; + } + + get attachmentsOutput() { + const ans = { media: this.attachments.value } as Record; + if (ans['media'].length == 0 || isEqual(ans['media'], this.campaign()?.media ?? [])) + delete ans['media']; + + return ans; + } + + get generalControls() { return this.form.controls.general.controls; } + get links() { + return this.form.controls.linksAndContacts.controls.links; + } + get linkControls() { return this.form.controls.linksAndContacts.controls.links.controls; } + get phones() { + return this.form.controls.linksAndContacts.controls.phones; + } + get phoneControls() { - return this.form.controls.linksAndContacts.controls.phoneNumbers.controls; + return this.form.controls.linksAndContacts.controls.phones.controls; + } + + get attachments() { + return this.form.controls.media; + } + + get attachmentControls() { + return this.attachments.controls; + } + + getCategory(id: number) { + return this.categories.value()?.find(c => c.id == id); + } + + getAttachmentType(url: string) { + return isImageOrVideoUrl(url); } getSamplePhoneNumber(code: string) { - const p = phoneUtil.getExampleNumber(code); + const p = phoneUtil.getExampleNumber(code); return phoneUtil.format(p, PhoneNumberFormat.NATIONAL); } - ngAfterViewInit(): void { - this.form.patchValue({}) + onBeforeUpload() { + this.uploading.set(true); } - addLinkControl() { - this.form.controls.linksAndContacts.controls.links.push(newLinkControl()); + onUploadFailed(event: FileUploadErrorEvent) { + this.uploading.set(false); + } + + onAttachmentFileUploaded(ev: FileUploadEvent) { + this.uploading.set(false); + const { body } = ev.originalEvent as HttpResponse<[string]>; + + if (!body) return; + const [url] = body; + + const control = newAttachmentControl(url); + this.attachments.insert(0, control); + control.markAsTouched(); + control.markAsDirty(); + this.attachments.updateValueAndValidity(); + } + + private doResetForm(input?: Campaign) { + this.links.clear(); + this.emails.clear(); + this.phones.clear(); + this.attachments.clear(); + + this.form.patchValue({ + general: { + title: input?.title, + categories: input?.categories ?? [], + description: input?.description, + redirectUrl: input?.redirectUrl ?? null + }, + media: input?.media + }); + + input?.links?.forEach((l, i) => this.addLinkControl(l, i)); + input?.emails?.forEach((e, i) => this.addEmailControl(e, i)); + input?.phones?.map(p => phoneUtil.parseAndKeepRawInput(p)) + .map(p => ({ code: phoneUtil.getRegionCodeForNumber(p), number: phoneUtil.format(p, PhoneNumberFormat.NATIONAL) })) + .forEach(({ code, number }, i) => this.addPhoneControl(code, number, i)); + input?.media?.forEach((m, i) => { + this.attachments.insert(i, newAttachmentControl(m)); + }); + this.form.markAsUntouched(); + this.form.markAsPristine(); + this.form.updateValueAndValidity(); + this.inPlaceControls().forEach(i => i.deactivate()); + } + + revertControl(campaignObjKey: string, control: AbstractControl, callback: () => void) { + const value = get(this.campaign(), campaignObjKey) + control.patchValue(value); + control.markAsPristine(); + control.markAsUntouched(); + control.updateValueAndValidity(); + callback(); + } + + addLinkControl(value?: string, index?: number) { + if (index !== undefined) + this.links.insert(index, newLinkControl(value)); + else + this.links.push(newLinkControl(value)); } removeLinkControl(index: number) { this.form.controls.linksAndContacts.controls.links.removeAt(index); + if (isEqual(this.links.value, this.campaign()?.links ?? [])) { + this.links.markAsUntouched(); + this.links.markAsPristine(); + } else if (!isEqual(this.links.value, this.campaign()?.links ?? [])) { + this.links.markAsDirty(); + this.links.markAsTouched(); + } + this.links.updateValueAndValidity(); } - addPhoneControl() { - this.form.controls.linksAndContacts.controls.phoneNumbers.push(newPhoneControl(this.form.controls.linksAndContacts.controls.phoneNumbers.at(-1)?.value.code ?? 'CM')); + addPhoneControl(code?: string, number?: string, index?: number) { + if (index !== undefined) + this.phones.insert(index, newPhoneControl(code ?? this.phones.at(-1)?.value.code ?? 'CM', number)); + else this.phones.push(newPhoneControl(code ?? this.phones.at(-1)?.value.code ?? 'CM', number)); } removePhoneControl(index: number) { - this.form.controls.linksAndContacts.controls.phoneNumbers.removeAt(index); + this.phones.removeAt(index); + const phones = this.phones.value.slice(0, this.phones.length - 1) + .map(({ code, number }) => { + return phoneUtil.parse(number ?? undefined, code ?? undefined); + }).map(p => phoneUtil.format(p, PhoneNumberFormat.E164)); + + if (isEqual(phones, this.campaign()?.phones ?? [])) { + this.phones.markAsUntouched(); + this.phones.markAsPristine(); + } else if (!isEqual(phones, this.campaign()?.phones ?? [])) { + this.phones.markAsTouched(); + this.phones.markAsDirty(); + } + this.phones.updateValueAndValidity(); } - addEmailControl() { - this.form.controls.linksAndContacts.controls.emails.push(newEmailControl()); + addEmailControl(value?: string, index?: number) { + if (index !== undefined) + this.emails.insert(index, newEmailControl(value)); + else + this.emails.push(newEmailControl(value)); } removeEmailControl(index: number) { - this.form.controls.linksAndContacts.controls.emails.removeAt(index); + this.emails.removeAt(index); + if (isEqual(this.emails.value, this.campaign()?.emails ?? [])) { + this.emails.markAsUntouched(); + this.emails.markAsPristine(); + } else if (!isEqual(this.emails.value, this.campaign()?.emails ?? [])) { + this.emails.markAsDirty(); + this.emails.markAsTouched(); + } + this.links.updateValueAndValidity(); + } + + removeAttachmentControl(index: number) { + this.attachments.removeAt(index); + if (isEqual(this.attachments.value, this.campaign()?.media ?? [])) { + this.attachments.markAsPristine(); + this.attachments.markAsUntouched(); + } else if (!isEqual(this.attachments.value, this.campaign()?.media ?? [])) { + this.attachments.markAsDirty(); + this.attachments.markAsTouched(); + } + this.attachments.updateValueAndValidity(); + } + + onSubmitButtonClicked() { + this.updating.set(true); + const request = { + ...this.attachmentsOutput, + ...this.modifiedGeneralOutput, + ...this.modifiedLinksAndContactsOutput + }; + + this.http.patch(`/api/campaigns/${this.campaign()?.id}`, request).subscribe({ + error: (error: HttpErrorResponse) => { + this.ms.add({ + severity: 'error', + summary: 'Submission error', + detail: error.error?.message ?? error.message, + key: 'pending-campaign-changes', + }); + this.updating.set(false); + }, + complete: () => { + this.updating.set(false); + this.ms.clear('pending-campaign-changes'); + this.pendingToastMessageVisible = false; + this.updated.emit(); + } + }) } - constructor() { - this.form.controls.linksAndContacts.controls.links.valueChanges.pipe( + onResetButtonClicked() { + this.doResetForm(this.campaign()); + } + + onDeleteCampaignButtonClicked(event: MouseEvent) { + this.cs.confirm({ + target: event.target as EventTarget, + message: 'Are you sure you want to proceed? This process cannot be undone', + header: 'Confirmation', + closable: true, + closeOnEscape: true, + icon: 'pi pi-exclamation-triangle', + rejectButtonProps: { + label: 'No', + severity: 'secondary', + text: true, + size: 'small', + rounded: true + }, + acceptButtonProps: { + label: 'Yes', + severity: 'danger', + size: 'small', + rounded: true + }, + accept: () => { + this.doDeleteCampaign(); + } + }) + } + + private doDeleteCampaign() { + this.deleting.set(true); + this.http.delete(`/api/campaigns/${this.campaign()?.id}`).subscribe({ + error: (error: HttpErrorResponse) => { + this.deleting.set(false); + this.error.emit(error?.error ?? error); + }, + complete: () => { + this.deleting.set(false); + this.deleted.emit(); + } + }) + } + + constructor(private ms: MessageService, private cs: ConfirmationService) { + effect(() => { + const value = this.campaign(); + this.doResetForm(value); + }); + + this.links.valueChanges.pipe( takeUntilDestroyed() ).subscribe(values => { if (!values.every(v => (v?.length ?? 0) > 0)) return; this.addLinkControl(); }); - this.form.controls.linksAndContacts.controls.phoneNumbers.valueChanges.pipe( + this.phones.valueChanges.pipe( takeUntilDestroyed() ).subscribe(values => { if (!values.every(v => (v.number?.length ?? 0) > 0)) return; this.addPhoneControl(); - }) - this.form.controls.linksAndContacts.controls.emails.valueChanges.pipe( + }); + + this.emails.valueChanges.pipe( takeUntilDestroyed(), ).subscribe(values => { if (!values.every(v => v.length > 0)) return; this.addEmailControl(); }); + + this.form.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(() => { + // const linksAndContactsPristine = this.linkControls.length == 1 && this.emailControls.length == 1 && this.phoneControls.length == 1; + const toastKey = 'pending-campaign-changes'; + + if (this.general.pristine && this.links.pristine && this.emails.pristine && this.phones.pristine && this.attachments.pristine) { + this.pendingToastMessageVisible = false; + ms.clear(toastKey); + return; + } + + if (this.pendingToastMessageVisible) return; + const toastId = 'pending-changes'; + const toastMessage: ToastMessageOptions = { + sticky: true, + id: toastId, + severity: 'info', + closable: false, + detail: 'Save pending changes?', + key: toastKey + } + ms.add(toastMessage); + this.pendingToastMessageVisible = true; + }) } } diff --git a/src/app/pages/campaign/campaign.component.html b/src/app/pages/campaign/campaign.component.html index 606b614..977d2b2 100644 --- a/src/app/pages/campaign/campaign.component.html +++ b/src/app/pages/campaign/campaign.component.html @@ -1,29 +1,53 @@ -@defer (when !campaign.isLoading()) { -
-

{{ campaign.value()?.title }}

- - - Analytics - Publications - Settings - - - - - - - - - - - - - +
+
+
+ +
+

{{ campaign.value()?.title }}

+
+
+ + + + Analytics + Publications + Settings + + + + @defer (when !campaign.isLoading()) { + + }@placeholder { +
+ +
+ } +
+ + @defer (when !campaign.isLoading()) { + + }@placeholder { +
+ +
+ } +
+ + @defer (when !campaign.isLoading()) { + + }@placeholder { +
+ +
+ } +
+
+
+
+
- -}@placeholder { -
- -
-} diff --git a/src/app/pages/campaign/campaign.component.ts b/src/app/pages/campaign/campaign.component.ts index 2743a03..fd57a28 100644 --- a/src/app/pages/campaign/campaign.component.ts +++ b/src/app/pages/campaign/campaign.component.ts @@ -1,12 +1,19 @@ import { HttpClient } from '@angular/common/http'; -import { Component, computed, inject } from '@angular/core'; +import { Component, computed, effect, inject } from '@angular/core'; import { rxResource } from '@angular/core/rxjs-interop'; +import { Title } from '@angular/platform-browser'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { CampaignAnalytics } from '@app/components/campaign-analytics/campaign-analytics.component'; import { CampaignPublications } from '@app/components/campaign-publications/campaign-publications.component'; import { CampaignSettings } from '@app/components/campaign-settings/campaign-settings.component'; import { Campaign } from '@lib/models/campaign'; +import { Navigate } from '@ngxs/router-plugin'; +import { dispatch } from '@ngxs/store'; import { injectParams } from 'ngxtension/inject-params'; import { injectQueryParams } from 'ngxtension/inject-query-params'; +import { MessageService } from 'primeng/api'; +import { Button } from 'primeng/button'; +import { Panel } from 'primeng/panel'; import { ProgressSpinner } from 'primeng/progressspinner'; import { Tab, TabList, TabPanel, TabPanels, Tabs } from 'primeng/tabs'; import { EMPTY } from 'rxjs'; @@ -18,17 +25,23 @@ import { EMPTY } from 'rxjs'; TabList, Tab, CampaignAnalytics, + Button, CampaignPublications, CampaignSettings, TabPanel, ProgressSpinner, - TabPanels + TabPanels, + Panel, + RouterLink ], templateUrl: './campaign.component.html', styleUrl: './campaign.component.scss' }) export class CampaignComponent { private http = inject(HttpClient); + private ms = inject(MessageService); + private navigate = dispatch(Navigate); + readonly currentRoute = inject(ActivatedRoute); readonly tabParam = injectQueryParams('activeTab', { initialValue: 'analytics' }); readonly activeTabIndex = computed(() => { const tabParam = this.tabParam(); @@ -47,4 +60,34 @@ export class CampaignComponent { return this.http.get(`/api/campaigns/${request}`) } }); + + onSettingsErrored(event: Error) { + this.ms.add({ + summary: 'Error', + severity: 'error', + detail: event.message + }); + } + + onCampaignUpdated() { + this.campaign.reload(); + this.ms.add({ + summary: 'Notification', + severity: 'success', + detail: 'Changes saved', + }) + } + + onCampaignDeleted() { + this.navigate(['..'], undefined, { relativeTo: this.currentRoute }); + } + + constructor(title: Title) { + effect(() => { + const campaign = this.campaign.value(); + if (campaign) { + title.setTitle(campaign.title + ' | ' + title.getTitle()); + } + }) + } } diff --git a/src/app/pages/campaigns/campaigns.component.html b/src/app/pages/campaigns/campaigns.component.html index 0ff081e..e3d23f8 100644 --- a/src/app/pages/campaigns/campaigns.component.html +++ b/src/app/pages/campaigns/campaigns.component.html @@ -37,7 +37,7 @@

Campaigns

Not set 1 category - {{ campaign.categoryCount }} categories + {{ campaign.categoryCount }} categories 99+ categories {{ campaign.updatedAt | date }}