From b7da6d4aab4ef3f2dc14f0511c9ee4351b5c67c6 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Thu, 8 Oct 2026 09:59:21 +0545 Subject: [PATCH] chore: update Console SDK to 18.3.0-rc.3 --- CHANGELOG.md | 9 + README.md | 4 +- .../account/create-o-auth-2-session.md | 1 + .../examples/account/create-o-auth-2-token.md | 1 + docs/examples/admin/delete-platform-cache.md | 13 + .../admin/delete-platform-resource-cache.md | 16 + .../chat.md => admin/delete-project-cache.md} | 8 +- .../admin/delete-project-resource-cache.md | 17 + docs/examples/analytics/create-event.md | 27 + docs/examples/analytics/create-property.md | 21 + docs/examples/analytics/delete-property.md | 15 + docs/examples/analytics/get-property.md | 15 + docs/examples/analytics/list-metrics.md | 27 + docs/examples/analytics/list-properties.md | 17 + docs/examples/analytics/update-property.md | 21 + docs/examples/usage/list-events.md | 2 +- docs/examples/usage/list-gauges.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/client.ts | 2 +- src/enums/analytics-dimension.ts | 24 + src/enums/analytics-interval.ts | 6 + src/enums/platform-resource-type.ts | 11 + src/enums/project-key-scopes.ts | 2 + src/enums/project-resource-type.ts | 13 + .../proxy-rule-deployment-resource-type.ts | 1 + src/enums/query-suggestion-resource.ts | 1 + src/enums/usage-event-metric.ts | 3 + src/enums/usage-gauge-metric.ts | 1 + src/index.ts | 15 +- src/models.ts | 244 +++-- src/service.ts | 30 - src/services/account.ts | 43 +- src/services/admin.ts | 264 +++++ src/services/analytics-tracking.ts | 764 +++++++++++++++ src/services/analytics.ts | 902 ++++++++++++++++++ src/services/assistant.ts | 62 -- src/services/avatars.ts | 37 +- src/services/functions.ts | 5 +- src/services/organizations.ts | 9 +- src/services/push.ts | 162 +++- src/services/sites.ts | 5 +- src/services/storage.ts | 13 +- 43 files changed, 2597 insertions(+), 244 deletions(-) create mode 100644 docs/examples/admin/delete-platform-cache.md create mode 100644 docs/examples/admin/delete-platform-resource-cache.md rename docs/examples/{assistant/chat.md => admin/delete-project-cache.md} (55%) create mode 100644 docs/examples/admin/delete-project-resource-cache.md create mode 100644 docs/examples/analytics/create-event.md create mode 100644 docs/examples/analytics/create-property.md create mode 100644 docs/examples/analytics/delete-property.md create mode 100644 docs/examples/analytics/get-property.md create mode 100644 docs/examples/analytics/list-metrics.md create mode 100644 docs/examples/analytics/list-properties.md create mode 100644 docs/examples/analytics/update-property.md create mode 100644 src/enums/analytics-dimension.ts create mode 100644 src/enums/analytics-interval.ts create mode 100644 src/enums/platform-resource-type.ts create mode 100644 src/enums/project-resource-type.ts delete mode 100644 src/service.ts create mode 100644 src/services/admin.ts create mode 100644 src/services/analytics-tracking.ts create mode 100644 src/services/analytics.ts delete mode 100644 src/services/assistant.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e670e54..7615d025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log + +## 18.3.0-rc.3 + +* Added: `Tracking` helper (was `AnalyticsTracking`) — construct it with the `Analytics` service and call `start()` to turn on the built-in events +* Changed: `Tracking` takes `new Analytics(client)` and a property id instead of an emitter callback; the adapter to `createEvent` now lives inside the SDK +* Changed: `enableAllAutoTracking()` is now `start()` +* Removed: `AnalyticsEventEmitter`; the option types are now `TrackingEventOptions` and `TrackingOptions` + ## 18.2.0 * Added: `Avatars.updatePhoto` and `Avatars.deletePhoto` to upload and remove the current user's profile photo; `updatePhoto` returns the new `Account` model @@ -543,3 +551,4 @@ Fix bigint and float parsing in SDK. * Adds support for `databasesReadsTotal` and `databasesWritesTotal` attributes + diff --git a/README.md b/README.md index 5c484ff2..7ac1bf3a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Twitter Account](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite) [![Discord](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord) -**This SDK targets Appwrite server version latest as shipped on Appwrite Cloud.** Self-hosted releases can lag behind Cloud — if you run an older self-hosted build, use a matching older SDK from [previous releases](https://github.com/appwrite/sdk-for-console/releases) when APIs differ. +**This SDK targets Appwrite server version 2.3.x as shipped on Appwrite Cloud.** Self-hosted releases can lag behind Cloud — if you run an older self-hosted build, use a matching older SDK from [previous releases](https://github.com/appwrite/sdk-for-console/releases) when APIs differ. Appwrite is an open-source backend as a service server that abstracts and simplifies complex and repetitive development tasks behind a very simple to use REST API. Appwrite aims to help you develop your apps faster and in a more secure way. Use the Console SDK to integrate your app with the Appwrite server to easily start interacting with all of Appwrite backend APIs and tools. For full API documentation and tutorials go to [https://appwrite.io/docs](https://appwrite.io/docs) @@ -33,7 +33,7 @@ import { Client, Account } from '@appwrite.io/console'; To install with a CDN (content delivery network) add the following scripts to the bottom of your tag, but before you use any Appwrite services: ```html - + ``` ## Getting Started diff --git a/docs/examples/account/create-o-auth-2-session.md b/docs/examples/account/create-o-auth-2-session.md index 735fd2ea..621103a2 100644 --- a/docs/examples/account/create-o-auth-2-session.md +++ b/docs/examples/account/create-o-auth-2-session.md @@ -12,5 +12,6 @@ account.createOAuth2Session({ success: 'https://example.com', // optional failure: 'https://example.com', // optional scopes: [], // optional + state: '', // optional }); ``` diff --git a/docs/examples/account/create-o-auth-2-token.md b/docs/examples/account/create-o-auth-2-token.md index cf961db6..1cb8437c 100644 --- a/docs/examples/account/create-o-auth-2-token.md +++ b/docs/examples/account/create-o-auth-2-token.md @@ -12,5 +12,6 @@ account.createOAuth2Token({ success: 'https://example.com', // optional failure: 'https://example.com', // optional scopes: [], // optional + state: '', // optional }); ``` diff --git a/docs/examples/admin/delete-platform-cache.md b/docs/examples/admin/delete-platform-cache.md new file mode 100644 index 00000000..7e31802e --- /dev/null +++ b/docs/examples/admin/delete-platform-cache.md @@ -0,0 +1,13 @@ +```javascript +import { Client, Admin } from '@appwrite.io/console'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const admin = new Admin(client); + +const result = await admin.deletePlatformCache(); + +console.log(result); +``` diff --git a/docs/examples/admin/delete-platform-resource-cache.md b/docs/examples/admin/delete-platform-resource-cache.md new file mode 100644 index 00000000..52b684d6 --- /dev/null +++ b/docs/examples/admin/delete-platform-resource-cache.md @@ -0,0 +1,16 @@ +```javascript +import { Client, Admin, PlatformResourceType } from '@appwrite.io/console'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const admin = new Admin(client); + +const result = await admin.deletePlatformResourceCache({ + resourceType: PlatformResourceType.Projects, + resourceId: '', // optional +}); + +console.log(result); +``` diff --git a/docs/examples/assistant/chat.md b/docs/examples/admin/delete-project-cache.md similarity index 55% rename from docs/examples/assistant/chat.md rename to docs/examples/admin/delete-project-cache.md index 2131548d..fb7e0dda 100644 --- a/docs/examples/assistant/chat.md +++ b/docs/examples/admin/delete-project-cache.md @@ -1,14 +1,14 @@ ```javascript -import { Client, Assistant } from '@appwrite.io/console'; +import { Client, Admin } from '@appwrite.io/console'; const client = new Client() .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint .setProject(''); // Your project ID -const assistant = new Assistant(client); +const admin = new Admin(client); -const result = await assistant.chat({ - prompt: '', +const result = await admin.deleteProjectCache({ + projectId: '', }); console.log(result); diff --git a/docs/examples/admin/delete-project-resource-cache.md b/docs/examples/admin/delete-project-resource-cache.md new file mode 100644 index 00000000..728f3a74 --- /dev/null +++ b/docs/examples/admin/delete-project-resource-cache.md @@ -0,0 +1,17 @@ +```javascript +import { Client, Admin, ProjectResourceType } from '@appwrite.io/console'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const admin = new Admin(client); + +const result = await admin.deleteProjectResourceCache({ + projectId: '', + resourceType: ProjectResourceType.Users, + resourceId: '', // optional +}); + +console.log(result); +``` diff --git a/docs/examples/analytics/create-event.md b/docs/examples/analytics/create-event.md new file mode 100644 index 00000000..b5201362 --- /dev/null +++ b/docs/examples/analytics/create-event.md @@ -0,0 +1,27 @@ +```javascript +import { Client, Analytics } from '@appwrite.io/console'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const analytics = new Analytics(client); + +const result = await analytics.createEvent({ + propertyId: '', + name: '', + url: 'https://example.com', + domain: '', // optional + referrer: '', // optional + screenWidth: null, // optional + sessionHash: '', // optional + scrollDepth: 0, // optional + engagementTime: 0, // optional + props: [], // optional + userId: '', // optional + ip: '', // optional + userAgent: '', // optional +}); + +console.log(result); +``` diff --git a/docs/examples/analytics/create-property.md b/docs/examples/analytics/create-property.md new file mode 100644 index 00000000..6bd67cf2 --- /dev/null +++ b/docs/examples/analytics/create-property.md @@ -0,0 +1,21 @@ +```javascript +import { Client, Analytics } from '@appwrite.io/console'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const analytics = new Analytics(client); + +const result = await analytics.createProperty({ + propertyId: '', + name: '', + domain: '', // optional + timezone: '', // optional + enabled: false, // optional + xpublic: false, // optional + allowedOrigins: [], // optional +}); + +console.log(result); +``` diff --git a/docs/examples/analytics/delete-property.md b/docs/examples/analytics/delete-property.md new file mode 100644 index 00000000..390ec323 --- /dev/null +++ b/docs/examples/analytics/delete-property.md @@ -0,0 +1,15 @@ +```javascript +import { Client, Analytics } from '@appwrite.io/console'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const analytics = new Analytics(client); + +const result = await analytics.deleteProperty({ + propertyId: '', +}); + +console.log(result); +``` diff --git a/docs/examples/analytics/get-property.md b/docs/examples/analytics/get-property.md new file mode 100644 index 00000000..9605b522 --- /dev/null +++ b/docs/examples/analytics/get-property.md @@ -0,0 +1,15 @@ +```javascript +import { Client, Analytics } from '@appwrite.io/console'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const analytics = new Analytics(client); + +const result = await analytics.getProperty({ + propertyId: '', +}); + +console.log(result); +``` diff --git a/docs/examples/analytics/list-metrics.md b/docs/examples/analytics/list-metrics.md new file mode 100644 index 00000000..675da6f1 --- /dev/null +++ b/docs/examples/analytics/list-metrics.md @@ -0,0 +1,27 @@ +```javascript +import { + Client, + Analytics, + AnalyticsInterval, + AnalyticsDimension, +} from '@appwrite.io/console'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const analytics = new Analytics(client); + +const result = await analytics.listMetrics({ + propertyId: '', + queries: [], // optional + interval: AnalyticsInterval.OneHour, // optional + dimensions: [AnalyticsDimension.Country], // optional + dateRange: '', // optional + startAt: '2020-10-15T06:38:00.000+00:00', // optional + endAt: '2020-10-15T06:38:00.000+00:00', // optional + limit: 1, // optional +}); + +console.log(result); +``` diff --git a/docs/examples/analytics/list-properties.md b/docs/examples/analytics/list-properties.md new file mode 100644 index 00000000..d543a7d5 --- /dev/null +++ b/docs/examples/analytics/list-properties.md @@ -0,0 +1,17 @@ +```javascript +import { Client, Analytics } from '@appwrite.io/console'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const analytics = new Analytics(client); + +const result = await analytics.listProperties({ + queries: [], // optional + search: '', // optional + total: false, // optional +}); + +console.log(result); +``` diff --git a/docs/examples/analytics/update-property.md b/docs/examples/analytics/update-property.md new file mode 100644 index 00000000..e31b2273 --- /dev/null +++ b/docs/examples/analytics/update-property.md @@ -0,0 +1,21 @@ +```javascript +import { Client, Analytics } from '@appwrite.io/console'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const analytics = new Analytics(client); + +const result = await analytics.updateProperty({ + propertyId: '', + name: '', // optional + domain: '', // optional + timezone: '', // optional + enabled: false, // optional + xpublic: false, // optional + allowedOrigins: [], // optional +}); + +console.log(result); +``` diff --git a/docs/examples/usage/list-events.md b/docs/examples/usage/list-events.md index 30b8d975..fe672527 100644 --- a/docs/examples/usage/list-events.md +++ b/docs/examples/usage/list-events.md @@ -16,7 +16,7 @@ const client = new Client() const usage = new Usage(client); const result = await usage.listEvents({ - metrics: [UsageEventMetric.NetworkRequests], + metrics: [UsageEventMetric.AnalyticsEvents], queries: [], // optional interval: UsageInterval.OneMinute, // optional dimensions: [UsageEventDimension.Path], // optional diff --git a/docs/examples/usage/list-gauges.md b/docs/examples/usage/list-gauges.md index 745ecf94..3cc965dc 100644 --- a/docs/examples/usage/list-gauges.md +++ b/docs/examples/usage/list-gauges.md @@ -16,7 +16,7 @@ const client = new Client() const usage = new Usage(client); const result = await usage.listGauges({ - metrics: [UsageGaugeMetric.Teams], + metrics: [UsageGaugeMetric.AnalyticsProperties], queries: [], // optional interval: UsageInterval.OneMinute, // optional dimensions: [UsageGaugeDimension.ResourceId], // optional diff --git a/package-lock.json b/package-lock.json index bc930ed4..fa8dc6c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@appwrite.io/console", - "version": "18.2.0", + "version": "18.3.0-rc.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@appwrite.io/console", - "version": "18.2.0", + "version": "18.3.0-rc.3", "license": "BSD-3-Clause", "dependencies": { "buffer": "6.0.3", diff --git a/package.json b/package.json index e12b00e4..adacdcc7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@appwrite.io/console", "homepage": "https://appwrite.io/support", "description": "Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API", - "version": "18.2.0", + "version": "18.3.0-rc.3", "license": "BSD-3-Clause", "main": "dist/cjs/sdk.js", "exports": { diff --git a/src/client.ts b/src/client.ts index 74051f54..565398d8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -413,7 +413,7 @@ class Client { 'x-sdk-name': 'Console', 'x-sdk-platform': 'console', 'x-sdk-language': 'web', - 'x-sdk-version': '18.2.0', + 'x-sdk-version': '18.3.0-rc.3', 'X-Appwrite-Response-Format': '2.3.0', }; diff --git a/src/enums/analytics-dimension.ts b/src/enums/analytics-dimension.ts new file mode 100644 index 00000000..db552069 --- /dev/null +++ b/src/enums/analytics-dimension.ts @@ -0,0 +1,24 @@ +export enum AnalyticsDimension { + Country = 'country', + Region = 'region', + City = 'city', + Browser = 'browser', + OperatingSystem = 'operatingSystem', + Device = 'device', + ScreenSize = 'screenSize', + ReferrerSource = 'referrerSource', + Channel = 'channel', + UtmSource = 'utmSource', + UtmMedium = 'utmMedium', + UtmCampaign = 'utmCampaign', + UtmContent = 'utmContent', + UtmTerm = 'utmTerm', + Page = 'page', + Hostname = 'hostname', + EntryPage = 'entryPage', + ExitPage = 'exitPage', + TrafficType = 'trafficType', + BotName = 'botName', + BotCategory = 'botCategory', + EventName = 'eventName', +} diff --git a/src/enums/analytics-interval.ts b/src/enums/analytics-interval.ts new file mode 100644 index 00000000..344f58e2 --- /dev/null +++ b/src/enums/analytics-interval.ts @@ -0,0 +1,6 @@ +export enum AnalyticsInterval { + OneHour = '1h', + OneDay = '1d', + OneWeek = '1w', + OneMonth = '1m', +} diff --git a/src/enums/platform-resource-type.ts b/src/enums/platform-resource-type.ts new file mode 100644 index 00000000..66dcef4a --- /dev/null +++ b/src/enums/platform-resource-type.ts @@ -0,0 +1,11 @@ +export enum PlatformResourceType { + Projects = 'projects', + Organizations = 'organizations', + Users = 'users', + Memberships = 'memberships', + Rules = 'rules', + Keys = 'keys', + Platforms = 'platforms', + Webhooks = 'webhooks', + Certificates = 'certificates', +} diff --git a/src/enums/project-key-scopes.ts b/src/enums/project-key-scopes.ts index 48ab459e..3cb8cc4f 100644 --- a/src/enums/project-key-scopes.ts +++ b/src/enums/project-key-scopes.ts @@ -122,4 +122,6 @@ export enum ProjectKeyScopes { Oauth2Read = 'oauth2.read', Oauth2Write = 'oauth2.write', Oauth2Introspect = 'oauth2.introspect', + AnalyticsRead = 'analytics.read', + AnalyticsWrite = 'analytics.write', } diff --git a/src/enums/project-resource-type.ts b/src/enums/project-resource-type.ts new file mode 100644 index 00000000..f1ee2f77 --- /dev/null +++ b/src/enums/project-resource-type.ts @@ -0,0 +1,13 @@ +export enum ProjectResourceType { + Users = 'users', + Teams = 'teams', + Memberships = 'memberships', + Databases = 'databases', + Buckets = 'buckets', + Functions = 'functions', + Sites = 'sites', + Deployments = 'deployments', + Topics = 'topics', + Providers = 'providers', + Messages = 'messages', +} diff --git a/src/enums/proxy-rule-deployment-resource-type.ts b/src/enums/proxy-rule-deployment-resource-type.ts index ef695072..2b1c942c 100644 --- a/src/enums/proxy-rule-deployment-resource-type.ts +++ b/src/enums/proxy-rule-deployment-resource-type.ts @@ -1,4 +1,5 @@ export enum ProxyRuleDeploymentResourceType { Function = 'function', Site = 'site', + DedicatedDatabase = 'dedicatedDatabase', } diff --git a/src/enums/query-suggestion-resource.ts b/src/enums/query-suggestion-resource.ts index 272418af..f9f354ed 100644 --- a/src/enums/query-suggestion-resource.ts +++ b/src/enums/query-suggestion-resource.ts @@ -88,6 +88,7 @@ export enum QuerySuggestionResource { ResourceTokens = 'resourcetokens', Transactions = 'transactions', TransactionLogs = 'transactionlogs', + AnalyticsProperties = 'analyticsproperties', DedicatedDatabases = 'dedicateddatabases', DedicatedDatabaseConfigs = 'dedicateddatabaseconfigs', DedicatedDatabaseRuntimes = 'dedicateddatabaseruntimes', diff --git a/src/enums/usage-event-metric.ts b/src/enums/usage-event-metric.ts index d71732d5..5e3c39da 100644 --- a/src/enums/usage-event-metric.ts +++ b/src/enums/usage-event-metric.ts @@ -1,4 +1,7 @@ export enum UsageEventMetric { + AnalyticsEvents = 'analytics.events', + AnalyticsPageviews = 'analytics.pageviews', + AnalyticsBotsEvents = 'analytics.bots.events', NetworkRequests = 'network.requests', NetworkInbound = 'network.inbound', NetworkOutbound = 'network.outbound', diff --git a/src/enums/usage-gauge-metric.ts b/src/enums/usage-gauge-metric.ts index 5de2cb5c..bad68bfa 100644 --- a/src/enums/usage-gauge-metric.ts +++ b/src/enums/usage-gauge-metric.ts @@ -1,4 +1,5 @@ export enum UsageGaugeMetric { + AnalyticsProperties = 'analytics.properties', Teams = 'teams', Users = 'users', Messages = 'messages', diff --git a/src/index.ts b/src/index.ts index 4d501ac5..8412285b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,11 +10,12 @@ export { Client, Query, AppwriteException } from './client'; export { Account } from './services/account'; export { Activities } from './services/activities'; +export { Admin } from './services/admin'; export { Affiliates } from './services/affiliates'; +export { Analytics } from './services/analytics'; export { Apps } from './services/apps'; export { Avatars } from './services/avatars'; export { Backups } from './services/backups'; -export { Assistant } from './services/assistant'; export { Console } from './services/console'; export { Databases } from './services/databases'; export { DocumentsDB } from './services/documents-db'; @@ -52,6 +53,7 @@ export { Waf } from './services/waf'; export { Webhooks } from './services/webhooks'; export { Realtime } from './services/realtime'; export { Push } from './services/push'; +export { Tracking } from './services/analytics-tracking'; export type { Models, Payload, @@ -61,10 +63,17 @@ export type { export type { RealtimeSubscription } from './services/realtime'; export type { PushMessage, + PushNotificationOpened, PushSubscription, SubscribeOptions, MessageCallback, } from './services/push'; +export type { + DownloadTrackingOptions, + OutboundTrackingOptions, + TrackingEventOptions, + TrackingOptions, +} from './services/analytics-tracking'; export type { QueryTypes, QueryTypesList } from './query'; export { Permission } from './permission'; export { Role } from './role'; @@ -77,6 +86,10 @@ export { AuthenticatorType } from './enums/authenticator-type'; export { AuthenticationFactor } from './enums/authentication-factor'; export { IdTokenProvider } from './enums/id-token-provider'; export { OAuthProvider } from './enums/o-auth-provider'; +export { PlatformResourceType } from './enums/platform-resource-type'; +export { ProjectResourceType } from './enums/project-resource-type'; +export { AnalyticsInterval } from './enums/analytics-interval'; +export { AnalyticsDimension } from './enums/analytics-dimension'; export { Browser } from './enums/browser'; export { CreditCard } from './enums/credit-card'; export { Flag } from './enums/flag'; diff --git a/src/models.ts b/src/models.ts index eb63642c..6c031b4b 100644 --- a/src/models.ts +++ b/src/models.ts @@ -7858,7 +7858,7 @@ export namespace Models { */ deploymentId: string; /** - * Type of deployment. Possible values are "function", "site". Used if rule's type is "deployment". + * Type of deployment resource: function, site, or dedicatedDatabase. */ deploymentResourceType?: ProxyRuleDeploymentResourceType; /** @@ -8053,10 +8053,6 @@ export namespace Models { * Defines if main domain is configured. If so, custom domains can be created. */ _APP_DOMAIN_ENABLED: boolean; - /** - * Defines if AI assistant is enabled. - */ - _APP_ASSISTANT_ENABLED: boolean; /** * A comma separated list of domains to use for site URLs. */ @@ -9351,6 +9347,120 @@ export namespace Models { resources: UsageResources[]; }; + /** + * AnalyticsMetric + */ + export type AnalyticsMetric = { + /** + * Dimension value this row covers. Null when no dimension was requested, empty when the dimension could not be derived for those events. + */ + value?: string; + /** + * Start of the time bucket this row covers, in ISO 8601. Null when no interval was requested. + */ + date?: string; + /** + * Unique visitors in the requested range. + */ + visitors: number; + /** + * Unique sessions in the requested range. + */ + sessions: number; + /** + * Total pageview events (events with name="pageview") in the requested range. Only the flat aggregate computes it; null on breakdown and time-series rows. + */ + pageviews?: number; + /** + * Total events in the requested range. + */ + events: number; + /** + * Total sessions with activity in the requested range. Only the flat aggregate computes it; null on breakdown and time-series rows. + */ + visits?: number; + /** + * Share of single-event sessions, as a percentage. Only the flat aggregate computes it; null on breakdown and time-series rows. + */ + bounceRate?: number; + /** + * Average session length in seconds, measured first event to last event. Single-event sessions count as 0. Only the flat aggregate computes it; null on breakdown and time-series rows. + */ + visitDuration?: number; + /** + * Average pageviews per session (pageviews divided by visits). Only the flat aggregate computes it; null on breakdown and time-series rows. + */ + viewsPerVisit?: number; + /** + * Average scroll depth across events, as a percentage. Only the flat aggregate computes it; null on breakdown and time-series rows. + */ + scrollDepth?: number; + /** + * Average engaged time per session in seconds, counting only foreground time. Sessions that reported no engagement are excluded. Only the flat aggregate computes it; null on breakdown and time-series rows. + */ + engagementTime?: number; + }; + + /** + * AnalyticsMetricList + */ + export type AnalyticsMetricList = { + /** + * Total number of metric rows returned. + */ + total: number; + /** + * Metric rows: one per time bucket, one per dimension value, or a single row for the flat aggregate. + */ + metrics: AnalyticsMetric[]; + }; + + /** + * AnalyticsProperty + */ + export type AnalyticsProperty = { + /** + * Analytics property ID. + */ + $id: string; + /** + * Property creation date in ISO 8601 format. + */ + $createdAt: string; + /** + * Property update date in ISO 8601 format. + */ + $updatedAt: string; + /** + * Human-readable name of the tracked website or application. + */ + name: string; + /** + * Primary domain being tracked (e.g. example.com). May be empty for native apps. + */ + domain: string; + /** + * IANA timezone used to define the daily boundary for stats. + */ + timezone: string; + /** + * Whether tracking is currently active. + */ + enabled: boolean; + /** + * Whether stats for this property are publicly viewable. + */ + public: boolean; + /** + * List of origins allowed to send tracking events. Use ["*"] to allow all. + */ + allowedOrigins: string[]; + /** + * Unique identifier for the tracking script snippet. + */ + snippetId: string; + }; + /** * Team */ @@ -9502,17 +9612,17 @@ export namespace Models { */ policyId: string; /** - * Archive size in bytes. + * Archive size in bytes. Null until the size is known. */ - size: number; + size?: number; /** * The status of the archive creation. Possible values: pending, processing, uploading, completed, failed, skipped. */ status: string; /** - * The backup start time. + * The backup start time. Null until the backup starts. */ - startedAt: string; + startedAt?: string; /** * Migration ID. */ @@ -9668,7 +9778,7 @@ export namespace Models { /** * Address line 2 */ - addressLine2: string; + addressLine2?: string; /** * Address country */ @@ -9680,11 +9790,11 @@ export namespace Models { /** * state */ - state: string; + state?: string; /** * postal code */ - postalCode: string; + postalCode?: string; }; /** @@ -9890,9 +10000,9 @@ export namespace Models { */ alertLimit: number; /** - * Additional resources + * Additional resources. Null when the plan does not support usage billing. */ - usage: UsageBillingPlan; + usage?: UsageBillingPlan; /** * Addons */ @@ -10242,7 +10352,7 @@ export namespace Models { /** * Branch port. Null until the backing reports one. */ - port: number; + port?: number; /** * Advertised catalog the client connects to. MySQL/MariaDB use default; Postgres uses the routing label. */ @@ -10554,11 +10664,11 @@ export namespace Models { /** * Product API that owns this database: tablesdb, documentsdb, vectorsdb, mysql, postgresql, or mongodb. */ - api: string; + api?: string; /** * Database engine: postgresql, mysql, or mongodb. Null until the backing reports one. */ - engine: string; + engine?: string; /** * Database engine version. */ @@ -10578,7 +10688,7 @@ export namespace Models { /** * Database port for connections. Derived from the engine when the backing has not reported one yet. */ - connectionPort: number; + connectionPort?: number; /** * Database username for connections. */ @@ -10590,7 +10700,7 @@ export namespace Models { /** * Committed generation of the primary connection credentials. Null until the rotation contract has been initialized. */ - credentialGeneration: number; + credentialGeneration?: number; /** * Full database connection string (URI format). */ @@ -10804,7 +10914,7 @@ export namespace Models { /** * Source database ID when restoring a backup into another database. */ - sourceDatabaseId: string; + sourceDatabaseId?: string; /** * Project ID. */ @@ -10812,7 +10922,7 @@ export namespace Models { /** * Backup ID used for restoration (null for PITR). */ - backupId: string; + backupId?: string; /** * Restoration type. Possible values: backup (restore from a specific backup snapshot), pitr (point-in-time recovery to a specific timestamp). */ @@ -10824,15 +10934,15 @@ export namespace Models { /** * Target time for PITR restoration in ISO 8601 format. */ - targetTime: string; + targetTime?: string; /** * Restoration start time in ISO 8601 format. */ - startedAt: string; + startedAt?: string; /** * Restoration completion time in ISO 8601 format. */ - completedAt: string; + completedAt?: string; /** * Error message if restoration failed. */ @@ -10864,9 +10974,9 @@ export namespace Models { */ uptime: number; /** - * Connection statistics. + * Connection statistics. Null when the database has not been probed. */ - connections: DatabaseStatusConnections; + connections?: DatabaseStatusConnections; /** * Requested replication sync mode. Possible values: async, sync, quorum. Compare with effectiveSyncMode for what the primary is enforcing. */ @@ -11208,7 +11318,7 @@ export namespace Models { /** * Discount amount */ - discount: number; + discount?: number; /** * Credits amount */ @@ -11296,7 +11406,7 @@ export namespace Models { /** * Discount amount */ - discount: number; + discount?: number; /** * Credits amount */ @@ -11580,7 +11690,7 @@ export namespace Models { /** * Client secret for processing failed payments in front-end */ - clientSecret: string; + clientSecret?: string; /** * Invoice status */ @@ -11592,7 +11702,7 @@ export namespace Models { /** * Last payment error associated with the invoice */ - lastError: string; + lastError?: string; /** * Invoice due date. */ @@ -11728,15 +11838,15 @@ export namespace Models { /** * Billing cycle start date. */ - billingStartDate: string; + billingStartDate?: string; /** * Current invoice cycle start date. */ - billingCurrentInvoiceDate: string; + billingCurrentInvoiceDate?: string; /** * Next invoice cycle start date. */ - billingNextInvoiceDate: string; + billingNextInvoiceDate?: string; /** * Start date of trial. */ @@ -11744,19 +11854,19 @@ export namespace Models { /** * Number of trial days. */ - billingTrialDays: number; + billingTrialDays?: number; /** * Current active aggregation id. */ - billingAggregationId: string; + billingAggregationId?: string; /** * Current active aggregation id. */ - billingInvoiceId: string; + billingInvoiceId?: string; /** * Default payment method. */ - paymentMethodId: string; + paymentMethodId?: string; /** * Default payment method. */ @@ -11888,39 +11998,39 @@ export namespace Models { /** * Expiry month of the payment method. */ - expiryMonth: number; + expiryMonth?: number; /** * Expiry year of the payment method. */ - expiryYear: number; + expiryYear?: number; /** * Last 4 digit of the payment method */ - last4: string; + last4?: string; /** * Payment method brand */ - brand: string; + brand?: string; /** * Name of the owner */ - name: string; + name?: string; /** * Mandate ID of the payment method */ - mandateId: string; + mandateId?: string; /** * Country of the payment method */ - country: string; + country?: string; /** * State of the payment method */ - state: string; + state?: string; /** * Last payment error associated with the payment method. */ - lastError: string; + lastError?: string; /** * True when it's the default payment method. */ @@ -12304,9 +12414,9 @@ export namespace Models { */ status: string; /** - * The backup start time. + * The restoration start time. Null until the restoration starts. */ - startedAt: string; + startedAt?: string; /** * Migration ID. */ @@ -12320,9 +12430,9 @@ export namespace Models { */ resources: string[]; /** - * Optional data in key-value object. + * Restoration options as a key-value object. */ - options: string; + options: object; }; /** @@ -12516,11 +12626,11 @@ export namespace Models { /** * Bandwidth additional resources */ - bandwidth: AdditionalResource; + bandwidth?: AdditionalResource; /** * Executions additional resources */ - executions: AdditionalResource; + executions?: AdditionalResource; /** * Member additional resources */ @@ -12528,11 +12638,11 @@ export namespace Models { /** * Realtime additional resources */ - realtime: AdditionalResource; + realtime?: AdditionalResource; /** * Realtime messages additional resources */ - realtimeMessages: AdditionalResource; + realtimeMessages?: AdditionalResource; /** * Realtime bandwidth additional resources */ @@ -12540,19 +12650,19 @@ export namespace Models { /** * Storage additional resources */ - storage: AdditionalResource; + storage?: AdditionalResource; /** * User additional resources */ - users: AdditionalResource; + users?: AdditionalResource; /** * GBHour additional resources */ - GBHours: AdditionalResource; + GBHours?: AdditionalResource; /** * Image transformation additional resources */ - imageTransformations: AdditionalResource; + imageTransformations?: AdditionalResource; /** * Credits additional resources */ @@ -12643,6 +12753,10 @@ export namespace Models { * Aggregated stats for total backups storage. */ backupsStorageTotal: number; + /** + * Aggregated stats for total custom user photos storage. + */ + avatarsStorageTotal: number; /** * Aggregated stats for total storage. */ @@ -13917,6 +14031,20 @@ export namespace Models { addons: Addon[]; }; + /** + * Analytics properties list + */ + export type AnalyticsPropertyList = { + /** + * Total number of properties that matched your query. + */ + total: number; + /** + * List of properties. + */ + properties: AnalyticsProperty[]; + }; + /** * Aggregation team list */ diff --git a/src/service.ts b/src/service.ts deleted file mode 100644 index f6020344..00000000 --- a/src/service.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Client } from './client'; -import type { Payload } from './client'; - -export class Service { - /** - * The size for chunked uploads in bytes. - */ - static CHUNK_SIZE = 5 * 1024 * 1024; // 5MB - - client: Client; - - constructor(client: Client) { - this.client = client; - } - - static flatten(data: Payload, prefix = ''): Payload { - let output: Payload = {}; - - for (const [key, value] of Object.entries(data)) { - const finalKey = prefix ? prefix + '[' + key + ']' : key; - if (Array.isArray(value)) { - output = { ...output, ...Service.flatten(value, finalKey) }; - } else { - output[finalKey] = value; - } - } - - return output; - } -} diff --git a/src/services/account.ts b/src/services/account.ts index b931f045..ea0ca8f8 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -1,4 +1,3 @@ -import { Service } from '../service'; import { AppwriteException, Client, type Payload } from '../client'; import type { Models } from '../models'; @@ -4252,6 +4251,7 @@ export class Account { * @param {string} params.success - URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string} params.failure - URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string[]} params.scopes - A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long. + * @param {string} params.state - An opaque value your app generates and keeps, for example in a cookie. It is returned unchanged as the `state` query parameter on the success and failure URLs, so your app can check that the sign-in it receives is one it started. Printable ASCII only (RFC 6749 Appendix A.5). Max length: 256 chars. * @throws {AppwriteException} * @returns {void | string} */ @@ -4260,6 +4260,7 @@ export class Account { success?: string; failure?: string; scopes?: string[]; + state?: string; }): void | string; /** * Allow the user to login to their account using the OAuth2 provider of their choice. Each OAuth2 provider should be enabled from the Appwrite console first. Use the success and failure arguments to provide a redirect URL's back to your app when login is completed. @@ -4273,6 +4274,7 @@ export class Account { * @param {string} success - URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string} failure - URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string[]} scopes - A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long. + * @param {string} state - An opaque value your app generates and keeps, for example in a cookie. It is returned unchanged as the `state` query parameter on the success and failure URLs, so your app can check that the sign-in it receives is one it started. Printable ASCII only (RFC 6749 Appendix A.5). Max length: 256 chars. * @throws {AppwriteException} * @returns {void | string} * @deprecated Use the object parameter style method for a better developer experience. @@ -4282,6 +4284,7 @@ export class Account { success?: string, failure?: string, scopes?: string[], + state?: string, ): void | string; createOAuth2Session( paramsOrFirst: @@ -4290,15 +4293,17 @@ export class Account { success?: string; failure?: string; scopes?: string[]; + state?: string; } | OAuthProvider, - ...rest: [string?, string?, string[]?] + ...rest: [string?, string?, string[]?, string?] ): void | string { let params: { provider: OAuthProvider; success?: string; failure?: string; scopes?: string[]; + state?: string; }; if ( @@ -4308,13 +4313,15 @@ export class Account { ('provider' in paramsOrFirst || 'success' in paramsOrFirst || 'failure' in paramsOrFirst || - 'scopes' in paramsOrFirst) + 'scopes' in paramsOrFirst || + 'state' in paramsOrFirst) ) { params = (paramsOrFirst || {}) as { provider: OAuthProvider; success?: string; failure?: string; scopes?: string[]; + state?: string; }; } else { params = { @@ -4322,6 +4329,7 @@ export class Account { success: rest[0] as string, failure: rest[1] as string, scopes: rest[2] as string[], + state: rest[3] as string, }; } @@ -4329,6 +4337,7 @@ export class Account { const success = params.success; const failure = params.failure; const scopes = params.scopes; + const state = params.state; if (typeof provider === 'undefined') { throw new AppwriteException( @@ -4349,13 +4358,14 @@ export class Account { if (typeof scopes !== 'undefined') { apiPayload['scopes'] = scopes; } + if (typeof state !== 'undefined') { + apiPayload['state'] = state; + } const uri = new URL(this.client.config.endpoint + apiPath); apiPayload['project'] = this.client.config.project; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -5146,6 +5156,7 @@ export class Account { * @param {string} params.success - URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string} params.failure - URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string[]} params.scopes - A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long. + * @param {string} params.state - An opaque value your app generates and keeps, for example in a cookie. It is returned unchanged as the `state` query parameter on the success and failure URLs, so your app can check that the sign-in it receives is one it started. Printable ASCII only (RFC 6749 Appendix A.5). Max length: 256 chars. * @throws {AppwriteException} * @returns {void | string} */ @@ -5154,6 +5165,7 @@ export class Account { success?: string; failure?: string; scopes?: string[]; + state?: string; }): void | string; /** * Allow the user to login to their account using the OAuth2 provider of their choice. Each OAuth2 provider should be enabled from the Appwrite console first. Use the success and failure arguments to provide a redirect URL's back to your app when login is completed. @@ -5168,6 +5180,7 @@ export class Account { * @param {string} success - URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string} failure - URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API. * @param {string[]} scopes - A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long. + * @param {string} state - An opaque value your app generates and keeps, for example in a cookie. It is returned unchanged as the `state` query parameter on the success and failure URLs, so your app can check that the sign-in it receives is one it started. Printable ASCII only (RFC 6749 Appendix A.5). Max length: 256 chars. * @throws {AppwriteException} * @returns {void | string} * @deprecated Use the object parameter style method for a better developer experience. @@ -5177,6 +5190,7 @@ export class Account { success?: string, failure?: string, scopes?: string[], + state?: string, ): void | string; createOAuth2Token( paramsOrFirst: @@ -5185,15 +5199,17 @@ export class Account { success?: string; failure?: string; scopes?: string[]; + state?: string; } | OAuthProvider, - ...rest: [string?, string?, string[]?] + ...rest: [string?, string?, string[]?, string?] ): void | string { let params: { provider: OAuthProvider; success?: string; failure?: string; scopes?: string[]; + state?: string; }; if ( @@ -5203,13 +5219,15 @@ export class Account { ('provider' in paramsOrFirst || 'success' in paramsOrFirst || 'failure' in paramsOrFirst || - 'scopes' in paramsOrFirst) + 'scopes' in paramsOrFirst || + 'state' in paramsOrFirst) ) { params = (paramsOrFirst || {}) as { provider: OAuthProvider; success?: string; failure?: string; scopes?: string[]; + state?: string; }; } else { params = { @@ -5217,6 +5235,7 @@ export class Account { success: rest[0] as string, failure: rest[1] as string, scopes: rest[2] as string[], + state: rest[3] as string, }; } @@ -5224,6 +5243,7 @@ export class Account { const success = params.success; const failure = params.failure; const scopes = params.scopes; + const state = params.state; if (typeof provider === 'undefined') { throw new AppwriteException( @@ -5244,13 +5264,14 @@ export class Account { if (typeof scopes !== 'undefined') { apiPayload['scopes'] = scopes; } + if (typeof state !== 'undefined') { + apiPayload['state'] = state; + } const uri = new URL(this.client.config.endpoint + apiPath); apiPayload['project'] = this.client.config.project; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } diff --git a/src/services/admin.ts b/src/services/admin.ts new file mode 100644 index 00000000..fe1dc8e4 --- /dev/null +++ b/src/services/admin.ts @@ -0,0 +1,264 @@ +import { AppwriteException, Client, type Payload } from '../client'; + +import { PlatformResourceType } from '../enums/platform-resource-type'; +import { ProjectResourceType } from '../enums/project-resource-type'; +export class Admin { + client: Client; + + constructor(client: Client) { + this.client = client; + } + + /** + * Delete everything cached from the platform database in the region serving the request. + * + * @throws {AppwriteException} + * @returns {Promise<{}>} + */ + deletePlatformCache(): Promise<{}> { + const apiPath = '/admin/cache/platform'; + const apiPayload: Payload = {}; + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + 'content-type': 'application/json', + accept: 'application/json', + }; + + return this.client.call('delete', uri, apiHeaders, apiPayload); + } + + /** + * Delete the cache of one platform resource, or of every resource of a type, in the region serving the request. + * + * @param {PlatformResourceType} params.resourceType - Resource type. + * @param {string} params.resourceId - Resource ID. Leave empty to delete the cache of every resource of this type. + * @throws {AppwriteException} + * @returns {Promise<{}>} + */ + deletePlatformResourceCache(params: { + resourceType: PlatformResourceType; + resourceId?: string; + }): Promise<{}>; + /** + * Delete the cache of one platform resource, or of every resource of a type, in the region serving the request. + * + * @param {PlatformResourceType} resourceType - Resource type. + * @param {string} resourceId - Resource ID. Leave empty to delete the cache of every resource of this type. + * @throws {AppwriteException} + * @returns {Promise<{}>} + * @deprecated Use the object parameter style method for a better developer experience. + */ + deletePlatformResourceCache( + resourceType: PlatformResourceType, + resourceId?: string, + ): Promise<{}>; + deletePlatformResourceCache( + paramsOrFirst: + | { resourceType: PlatformResourceType; resourceId?: string } + | PlatformResourceType, + ...rest: [string?] + ): Promise<{}> { + let params: { resourceType: PlatformResourceType; resourceId?: string }; + + if ( + paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst) && + ('resourceType' in paramsOrFirst || 'resourceId' in paramsOrFirst) + ) { + params = (paramsOrFirst || {}) as { + resourceType: PlatformResourceType; + resourceId?: string; + }; + } else { + params = { + resourceType: paramsOrFirst as PlatformResourceType, + resourceId: rest[0] as string, + }; + } + + const resourceType = params.resourceType; + const resourceId = params.resourceId; + + if (typeof resourceType === 'undefined') { + throw new AppwriteException( + 'Missing required parameter: "resourceType"', + ); + } + const apiPath = '/admin/cache/platform/resources'; + const apiPayload: Payload = {}; + if (typeof resourceType !== 'undefined') { + apiPayload['resourceType'] = resourceType; + } + if (typeof resourceId !== 'undefined') { + apiPayload['resourceId'] = resourceId; + } + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + 'content-type': 'application/json', + accept: 'application/json', + }; + + return this.client.call('delete', uri, apiHeaders, apiPayload); + } + + /** + * Delete everything cached for a project. Send the request to the project's region. + * + * @param {string} params.projectId - Project ID. + * @throws {AppwriteException} + * @returns {Promise<{}>} + */ + deleteProjectCache(params: { projectId: string }): Promise<{}>; + /** + * Delete everything cached for a project. Send the request to the project's region. + * + * @param {string} projectId - Project ID. + * @throws {AppwriteException} + * @returns {Promise<{}>} + * @deprecated Use the object parameter style method for a better developer experience. + */ + deleteProjectCache(projectId: string): Promise<{}>; + deleteProjectCache( + paramsOrFirst: { projectId: string } | string, + ): Promise<{}> { + let params: { projectId: string }; + + if ( + paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst) + ) { + params = (paramsOrFirst || {}) as { projectId: string }; + } else { + params = { + projectId: paramsOrFirst as string, + }; + } + + const projectId = params.projectId; + + if (typeof projectId === 'undefined') { + throw new AppwriteException( + 'Missing required parameter: "projectId"', + ); + } + const apiPath = '/admin/cache/project'; + const apiPayload: Payload = {}; + if (typeof projectId !== 'undefined') { + apiPayload['projectId'] = projectId; + } + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + 'content-type': 'application/json', + accept: 'application/json', + }; + + return this.client.call('delete', uri, apiHeaders, apiPayload); + } + + /** + * Delete the cache of one project resource, or of every resource of a type. Send the request to the project's region. + * + * @param {string} params.projectId - Project ID. + * @param {ProjectResourceType} params.resourceType - Resource type. + * @param {string} params.resourceId - Resource ID. Leave empty to delete the cache of every resource of this type. + * @throws {AppwriteException} + * @returns {Promise<{}>} + */ + deleteProjectResourceCache(params: { + projectId: string; + resourceType: ProjectResourceType; + resourceId?: string; + }): Promise<{}>; + /** + * Delete the cache of one project resource, or of every resource of a type. Send the request to the project's region. + * + * @param {string} projectId - Project ID. + * @param {ProjectResourceType} resourceType - Resource type. + * @param {string} resourceId - Resource ID. Leave empty to delete the cache of every resource of this type. + * @throws {AppwriteException} + * @returns {Promise<{}>} + * @deprecated Use the object parameter style method for a better developer experience. + */ + deleteProjectResourceCache( + projectId: string, + resourceType: ProjectResourceType, + resourceId?: string, + ): Promise<{}>; + deleteProjectResourceCache( + paramsOrFirst: + | { + projectId: string; + resourceType: ProjectResourceType; + resourceId?: string; + } + | string, + ...rest: [ProjectResourceType?, string?] + ): Promise<{}> { + let params: { + projectId: string; + resourceType: ProjectResourceType; + resourceId?: string; + }; + + if ( + paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst) + ) { + params = (paramsOrFirst || {}) as { + projectId: string; + resourceType: ProjectResourceType; + resourceId?: string; + }; + } else { + params = { + projectId: paramsOrFirst as string, + resourceType: rest[0] as ProjectResourceType, + resourceId: rest[1] as string, + }; + } + + const projectId = params.projectId; + const resourceType = params.resourceType; + const resourceId = params.resourceId; + + if (typeof projectId === 'undefined') { + throw new AppwriteException( + 'Missing required parameter: "projectId"', + ); + } + if (typeof resourceType === 'undefined') { + throw new AppwriteException( + 'Missing required parameter: "resourceType"', + ); + } + const apiPath = '/admin/cache/project/resources'; + const apiPayload: Payload = {}; + if (typeof projectId !== 'undefined') { + apiPayload['projectId'] = projectId; + } + if (typeof resourceType !== 'undefined') { + apiPayload['resourceType'] = resourceType; + } + if (typeof resourceId !== 'undefined') { + apiPayload['resourceId'] = resourceId; + } + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + 'content-type': 'application/json', + accept: 'application/json', + }; + + return this.client.call('delete', uri, apiHeaders, apiPayload); + } +} diff --git a/src/services/analytics-tracking.ts b/src/services/analytics-tracking.ts new file mode 100644 index 00000000..04f6b31b --- /dev/null +++ b/src/services/analytics-tracking.ts @@ -0,0 +1,764 @@ +import type { Analytics } from './analytics'; + +/** + * Auto-tracking helpers for the generated `Analytics` service: pageviews, + * outbound links, downloads, scroll depth and active engagement, all sent + * through `Analytics.createEvent`. + * + * ```ts + * const tracking = new Tracking(new Analytics(client), ''); + * tracking.start(); + * ``` + * + * All helpers respect `navigator.doNotTrack === '1'` by default; opt out with + * `respectDoNotTrack: false` in the constructor options. + * + * Engagement time follows Plausible's model: rather than one total at unload, + * a delta is emitted at every natural checkpoint of the visit (tab hidden, SPA + * route change, unload). See `enableAutoEngagementTime` for details. + * + * Auto-emitted event names follow `snake_case` + lowercase (`pageview`, + * `outbound_link`, `file_download`, `screen_view`, etc.). Any prop keys use + * camelCase to match the endpoint's parameter naming. Callers can pass any + * custom event name; the auto-track methods only own the events they emit + * themselves. + */ + +export type TrackingEventOptions = { + props?: Record; + url?: string; // defaults to the current page URL + referrer?: string; + scrollDepth?: number; // 0-100 percentage + engagementTime?: number; // seconds since the previous flush +}; + +export type TrackingOptions = { + /** + * When true (default) the tracker becomes a no-op if `navigator.doNotTrack` + * is set to `'1'`. Set to false to always fire regardless of the DNT signal. + */ + respectDoNotTrack?: boolean; +}; + +export type OutboundTrackingOptions = { + /** + * When true, links pointing at the current host's subdomains are treated + * as outbound. Defaults to false (subdomains are same-origin for tracking). + */ + includeSubdomains?: boolean; +}; + +export type DownloadTrackingOptions = { + /** + * File extensions (without the leading dot) that mark an anchor as a + * download. Defaults cover the common set. + */ + extensions?: string[]; +}; + +const DEFAULT_DOWNLOAD_EXTENSIONS = [ + 'pdf', + 'zip', + 'rar', + '7z', + 'tar', + 'gz', + 'csv', + 'tsv', + 'xls', + 'xlsx', + 'doc', + 'docx', + 'ppt', + 'pptx', + 'dmg', + 'exe', + 'msi', + 'pkg', + 'deb', + 'rpm', + 'apk', + 'mp3', + 'wav', + 'flac', + 'mp4', + 'mov', + 'avi', + 'mkv', + 'webm', +]; + +/** + * Two-part public suffixes common enough to matter for outbound-link detection. + * See `rootDomain()` for why this is a short list rather than the Public Suffix + * List. + */ +const MULTI_PART_SUFFIXES = new Set([ + 'co.uk', + 'org.uk', + 'ac.uk', + 'gov.uk', + 'me.uk', + 'net.uk', + 'sch.uk', + 'co.jp', + 'ne.jp', + 'or.jp', + 'ac.jp', + 'go.jp', + 'com.au', + 'net.au', + 'org.au', + 'edu.au', + 'gov.au', + 'com.br', + 'net.br', + 'org.br', + 'com.cn', + 'net.cn', + 'org.cn', + 'gov.cn', + 'co.in', + 'net.in', + 'org.in', + 'co.nz', + 'net.nz', + 'org.nz', + 'co.za', + 'org.za', + 'com.mx', + 'com.ar', + 'com.co', + 'com.pe', + 'com.tr', + 'com.tw', + 'com.sg', + 'com.hk', + 'com.my', + 'com.ph', + 'com.vn', + 'co.kr', + 'co.il', + 'co.id', + 'co.th', + 'com.ua', + 'com.pl', + 'com.es', +]); + +const SCROLL_DEPTH_THROTTLE_MS = 200; + +type Cleanup = () => void; + +export class Tracking { + private readonly analytics: Analytics; + private readonly propertyId: string; + private readonly respectDoNotTrack: boolean; + + private pageviewCleanups: Cleanup[] = []; + private outboundCleanup?: Cleanup; + private downloadCleanup?: Cleanup; + private scrollCleanups: Cleanup[] = []; + private engagementCleanups: Cleanup[] = []; + + private currentPath?: string; + private maxScrollDepth = 0; + private scrollTicking = false; + private lastScrollReport = 0; + + private engagementStart = 0; + private engagementAccumulatedMs = 0; + private engagementActive = false; + + /** + * @param analytics - The generated `Analytics` service events are sent through. + * @param propertyId - Analytics property every event is recorded against. + */ + constructor( + analytics: Analytics, + propertyId: string, + options: TrackingOptions = {}, + ) { + this.analytics = analytics; + this.propertyId = propertyId; + this.respectDoNotTrack = options.respectDoNotTrack !== false; + } + + /** + * Start the default auto-tracking set: pageviews, outbound links, scroll + * depth and active engagement time. Downloads are opt-in via + * `enableAutoDownloadTracking()` because the extension list is + * application-specific. + */ + public start(): void { + this.enableAutoPageviews(); + this.enableAutoOutboundTracking(); + this.enableAutoScrollDepth(); + this.enableAutoEngagementTime(); + } + + /** + * Track an event manually. Respects the DNT setting and swallows request + * failures so tracking never surfaces errors to the host page. + */ + public track(name: string, options: TrackingEventOptions = {}): void { + if (!this.canTrack()) { + return; + } + try { + // Missing required params throw synchronously rather than rejecting. + this.analytics + .createEvent({ + propertyId: this.propertyId, + name, + url: options.url ?? this.currentPageUrl(), + referrer: options.referrer, + scrollDepth: options.scrollDepth, + engagementTime: options.engagementTime, + // The endpoint takes props as a flat alternating key/value list. + props: options.props + ? Object.entries(options.props).flatMap( + ([key, value]) => [key, String(value)], + ) + : undefined, + }) + .catch((error) => { + console.warn('Tracking: failed to send event', error); + }); + } catch (error) { + console.warn('Tracking: failed to send event', error); + } + } + + /** + * Convenience wrapper for pageview-shaped events. + */ + public pageview(url?: string, referrer?: string): void { + this.track('pageview', { + url: url ?? this.currentPageUrl(), + referrer: + referrer ?? + (typeof document !== 'undefined' + ? document.referrer + : undefined), + }); + } + + /** + * Enable SPA-aware pageview tracking. Fires an initial pageview on + * enable, then again on every `pushState`, `replaceState`, `popstate` + * or hashchange. Idempotent — repeat calls no-op. + */ + public enableAutoPageviews(): void { + if (this.pageviewCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof history === 'undefined') { + return; + } + + this.currentPath = this.currentPageUrl(); + this.pageview(); + + const handleNavigation = (): void => { + const nextPath = this.currentPageUrl(); + if (nextPath === this.currentPath) { + return; + } + const previous = this.currentPath; + this.currentPath = nextPath; + this.flushScrollDepth(previous); + this.flushEngagement(previous); + this.pageview(nextPath); + // flushEngagement no longer auto-restarts the timer (see its + // implementation note re: bfcache). SPA navigation stays on the + // same document, so restart engagement tracking for the new + // route as long as the tab is visible. + if ( + this.engagementCleanups.length > 0 && + (typeof document === 'undefined' || + document.visibilityState !== 'hidden') + ) { + this.startEngagement(); + } + }; + + const originalPushState = history.pushState.bind(history); + const originalReplaceState = history.replaceState.bind(history); + + // Our wrappers stay reachable after cleanup whenever another library + // patched on top of ours, so they have to be switchable off rather than + // merely unreferenced. + let patchActive = true; + + const patchedPushState = ( + data: unknown, + unused: string, + url?: string | URL | null, + ): void => { + originalPushState(data, unused, url); + if (patchActive) { + handleNavigation(); + } + }; + const patchedReplaceState = ( + data: unknown, + unused: string, + url?: string | URL | null, + ): void => { + originalReplaceState(data, unused, url); + if (patchActive) { + handleNavigation(); + } + }; + + history.pushState = patchedPushState; + history.replaceState = patchedReplaceState; + + const popstateHandler = (): void => handleNavigation(); + const hashchangeHandler = (): void => handleNavigation(); + + window.addEventListener('popstate', popstateHandler); + window.addEventListener('hashchange', hashchangeHandler); + + this.pageviewCleanups.push(() => { + // Only unwind our own patch. A router (Next.js App Router, React + // Router, …) that patched these after us owns the current value, + // and assigning our saved reference back would silently throw its + // wrapper away. In that case ours stays in the chain, which is why + // `patchActive` exists: the wrapper keeps delegating to the original + // but stops reporting. + patchActive = false; + + if (history.pushState === patchedPushState) { + history.pushState = originalPushState; + } + if (history.replaceState === patchedReplaceState) { + history.replaceState = originalReplaceState; + } + window.removeEventListener('popstate', popstateHandler); + window.removeEventListener('hashchange', hashchangeHandler); + }); + } + + /** + * Disable auto-pageview tracking installed by `enableAutoPageviews`. + * Safe to call when auto-pageviews were never enabled. + */ + public disableAutoPageviews(): void { + for (const cleanup of this.pageviewCleanups) { + cleanup(); + } + this.pageviewCleanups = []; + } + + /** + * Enable click-based outbound link tracking. Detects anchor clicks that + * navigate to a different origin and fires an `outbound_link` event + * (with `hostname` and `href` props) before the browser navigates. + */ + public enableAutoOutboundTracking( + options: OutboundTrackingOptions = {}, + ): void { + if (this.outboundCleanup) { + return; + } + if (typeof document === 'undefined') { + return; + } + + const includeSubdomains = options.includeSubdomains === true; + + const clickHandler = (event: MouseEvent): void => { + const anchor = this.findAnchor(event.target); + if (!anchor || !anchor.href) { + return; + } + const target = this.parseUrl(anchor.href); + if (!target || !this.isExternal(target, includeSubdomains)) { + return; + } + this.track('outbound_link', { + url: target.href, + props: { hostname: target.hostname, href: target.href }, + }); + }; + + document.addEventListener('click', clickHandler, { capture: true }); + this.outboundCleanup = () => + document.removeEventListener('click', clickHandler, { + capture: true, + }); + } + + /** + * Disable outbound-link tracking installed by `enableAutoOutboundTracking`. + * Safe to call when auto-outbound was never enabled. + */ + public disableAutoOutboundTracking(): void { + if (this.outboundCleanup) { + this.outboundCleanup(); + this.outboundCleanup = undefined; + } + } + + /** + * Enable click-based download tracking. Detects anchor clicks that point + * at a file whose extension appears in the configured list and fires a + * `file_download` event with `filename`, `extension` and `href` props. + */ + public enableAutoDownloadTracking( + options: DownloadTrackingOptions = {}, + ): void { + if (this.downloadCleanup) { + return; + } + if (typeof document === 'undefined') { + return; + } + + const extensions = ( + options.extensions ?? DEFAULT_DOWNLOAD_EXTENSIONS + ).map((ext) => ext.toLowerCase().replace(/^\./, '')); + const extensionSet = new Set(extensions); + + const clickHandler = (event: MouseEvent): void => { + const anchor = this.findAnchor(event.target); + if (!anchor || !anchor.href) { + return; + } + const url = this.parseUrl(anchor.href); + if (!url) { + return; + } + const filename = url.pathname.split('/').pop() ?? ''; + const extension = filename.includes('.') + ? filename.split('.').pop()?.toLowerCase() + : undefined; + if (!extension || !extensionSet.has(extension)) { + return; + } + this.track('file_download', { + url: url.href, + props: { filename, extension, href: url.href }, + }); + }; + + document.addEventListener('click', clickHandler, { capture: true }); + this.downloadCleanup = () => + document.removeEventListener('click', clickHandler, { + capture: true, + }); + } + + /** + * Disable file-download tracking installed by `enableAutoDownloadTracking`. + * Safe to call when auto-download was never enabled. + */ + public disableAutoDownloadTracking(): void { + if (this.downloadCleanup) { + this.downloadCleanup(); + this.downloadCleanup = undefined; + } + } + + /** + * Enable passive scroll-depth tracking. Records the maximum percent of the + * document scrolled and reports it on `beforeunload` and on SPA route + * changes. Uses a throttled passive listener so it is safe on hot paths. + */ + public enableAutoScrollDepth(): void { + if (this.scrollCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + this.maxScrollDepth = 0; + this.lastScrollReport = 0; + + const scrollHandler = (): void => { + if (this.scrollTicking) { + return; + } + this.scrollTicking = true; + window.requestAnimationFrame(() => { + const now = Date.now(); + if (now - this.lastScrollReport < SCROLL_DEPTH_THROTTLE_MS) { + this.scrollTicking = false; + return; + } + this.lastScrollReport = now; + const depth = this.computeScrollDepth(); + if (depth > this.maxScrollDepth) { + this.maxScrollDepth = depth; + } + this.scrollTicking = false; + }); + }; + + const unloadHandler = (): void => { + this.flushScrollDepth(this.currentPageUrl()); + }; + + window.addEventListener('scroll', scrollHandler, { passive: true }); + window.addEventListener('beforeunload', unloadHandler); + window.addEventListener('pagehide', unloadHandler); + + this.scrollCleanups.push(() => { + window.removeEventListener('scroll', scrollHandler); + window.removeEventListener('beforeunload', unloadHandler); + window.removeEventListener('pagehide', unloadHandler); + }); + } + + /** + * Disable scroll-depth tracking installed by `enableAutoScrollDepth`. Safe + * to call when auto-scroll-depth was never enabled. Any accumulated depth + * for the current page is dropped. + */ + public disableAutoScrollDepth(): void { + for (const cleanup of this.scrollCleanups) { + cleanup(); + } + this.scrollCleanups = []; + this.maxScrollDepth = 0; + this.scrollTicking = false; + this.lastScrollReport = 0; + } + + /** + * Enable active engagement-time tracking. Counts time the tab is visible + * and uses `visibilitychange` to exclude background time. + * + * Delivery follows Plausible's model rather than a single total at the + * end of the visit: an `engagement_time` event is emitted at every natural + * checkpoint — the tab going hidden, an SPA route change, and finally + * `beforeunload`/`pagehide`. Unload is kept only as a best-effort last + * flush; it is the least reliable moment to send anything (browsers + * routinely cancel in-flight requests there, and `sendBeacon` cannot set + * the `X-Appwrite-Project` header the endpoint requires), so it must never + * be the only delivery. + * + * Each event carries a **delta** — the seconds accrued since the previous + * flush, not a running total — so the values are additive per session and + * a lost event costs only its own slice. + * + * There is deliberately no periodic ping. The cadence is driven by + * navigation and tab switches, so request volume tracks what the visitor + * actually does instead of multiplying with time on page. + */ + public enableAutoEngagementTime(): void { + if (this.engagementCleanups.length > 0) { + return; + } + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + this.engagementAccumulatedMs = 0; + // Do not start the clock for a tab that is already hidden (prerender, + // background tab restore); `visibilitychange` starts it when it is + // actually looked at. + if (document.visibilityState !== 'hidden') { + this.startEngagement(); + } + + const visibilityHandler = (): void => { + if (document.visibilityState === 'hidden') { + // Flush, don't just pause. The page is still fully alive here, + // which makes this the most reliable delivery point in the + // whole visit — and it is the same moment Plausible reports on. + this.flushEngagement(this.currentPageUrl()); + } else if (document.visibilityState === 'visible') { + this.startEngagement(); + } + }; + + const unloadHandler = (): void => { + this.flushEngagement(this.currentPageUrl()); + }; + + const pageshowHandler = (event: PageTransitionEvent): void => { + // When the page is restored from the back-forward cache the + // engagement timer is in a stopped state (flushEngagement paused + // it on pagehide and does not auto-restart). Kick off a fresh + // engagementStart so bfcache dwell time is not billed as + // engagement. + if (event.persisted) { + this.startEngagement(); + } + }; + + document.addEventListener('visibilitychange', visibilityHandler); + window.addEventListener('beforeunload', unloadHandler); + window.addEventListener('pagehide', unloadHandler); + window.addEventListener('pageshow', pageshowHandler); + + this.engagementCleanups.push(() => { + document.removeEventListener('visibilitychange', visibilityHandler); + window.removeEventListener('beforeunload', unloadHandler); + window.removeEventListener('pagehide', unloadHandler); + window.removeEventListener('pageshow', pageshowHandler); + }); + } + + /** + * Disable engagement-time tracking installed by `enableAutoEngagementTime`. + * Safe to call when auto-engagement was never enabled. Any accumulated + * engagement time is dropped without being emitted. + */ + public disableAutoEngagementTime(): void { + for (const cleanup of this.engagementCleanups) { + cleanup(); + } + this.engagementCleanups = []; + this.engagementActive = false; + this.engagementAccumulatedMs = 0; + this.engagementStart = 0; + } + + private canTrack(): boolean { + if (!this.respectDoNotTrack) { + return true; + } + if (typeof navigator === 'undefined') { + return true; + } + return navigator.doNotTrack !== '1'; + } + + private currentPageUrl(): string { + if (typeof window === 'undefined' || !window.location) { + return ''; + } + return window.location.href; + } + + private findAnchor(target: EventTarget | null): HTMLAnchorElement | null { + let node = target as Node | null; + while (node) { + if ((node as HTMLElement).tagName === 'A') { + return node as HTMLAnchorElement; + } + node = (node as Node).parentNode; + } + return null; + } + + private parseUrl(href: string): URL | null { + try { + return new URL(href, window.location.href); + } catch { + return null; + } + } + + private isExternal(target: URL, includeSubdomains: boolean): boolean { + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + return false; + } + if (target.hostname === window.location.hostname) { + return false; + } + if (includeSubdomains) { + return true; + } + const root = this.rootDomain(window.location.hostname); + return this.rootDomain(target.hostname) !== root; + } + + private rootDomain(hostname: string): string { + const parts = hostname.split('.'); + if (parts.length <= 2) { + return hostname; + } + // Taking the last two labels is wrong wherever the registry sells names + // one level down: `shop.example.co.uk` and `google.co.uk` both reduce to + // `co.uk`, so every outbound click between two `.co.uk` sites looks + // internal and is dropped. Take three labels after a known two-part + // suffix instead. + // + // This is a heuristic, not the Public Suffix List — the full list is + // orders of magnitude larger than this whole file, and a tracking + // snippet cannot carry it. Sites on a suffix that is missing here can + // pass `includeSubdomains: true` to treat every other host as outbound. + const suffix = parts.slice(-2).join('.'); + if (parts.length > 2 && MULTI_PART_SUFFIXES.has(suffix)) { + return parts.slice(-3).join('.'); + } + return suffix; + } + + private computeScrollDepth(): number { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const viewport = + window.innerHeight || document.documentElement.clientHeight; + const total = document.documentElement.scrollHeight; + if (total <= viewport) { + return 100; + } + const depth = Math.round(((scrollTop + viewport) / total) * 100); + return Math.max(0, Math.min(100, depth)); + } + + private flushScrollDepth(url: string | undefined): void { + if (this.scrollCleanups.length === 0) { + return; + } + if (this.maxScrollDepth <= 0) { + return; + } + this.track('scroll_depth', { + url, + scrollDepth: this.maxScrollDepth, + }); + this.maxScrollDepth = 0; + } + + private startEngagement(): void { + if (this.engagementActive) { + return; + } + this.engagementActive = true; + this.engagementStart = Date.now(); + } + + private pauseEngagement(): void { + if (!this.engagementActive) { + return; + } + this.engagementActive = false; + this.engagementAccumulatedMs += Date.now() - this.engagementStart; + } + + private flushEngagement(url: string | undefined): void { + if (this.engagementCleanups.length === 0) { + return; + } + this.pauseEngagement(); + // Deltas, not a running total: emit only what accrued since the last + // flush. `floor` plus keeping the sub-second remainder in the + // accumulator is what makes repeated flushes safe — nothing is emitted + // twice, and a visit chopped into many short segments does not lose a + // fraction of a second to rounding on every one of them. + const seconds = Math.floor(this.engagementAccumulatedMs / 1000); + if (seconds > 0) { + this.engagementAccumulatedMs -= seconds * 1000; + this.track('engagement_time', { + url, + engagementTime: seconds, + }); + } + // NOTE: intentionally do NOT call startEngagement() here. flushEngagement + // runs on `pagehide`, which is also the last hook before the browser + // may freeze the page into the back-forward cache. Auto-restarting + // would snapshot engagementStart just before the freeze; the next + // pauseEngagement (after bfcache restore) would then bill the entire + // bfcache dwell as active engagement. Restart is driven by explicit + // resume signals: `pageshow` (with `persisted === true`), + // `visibilitychange` back to visible, or the SPA navigation flow in + // `enableAutoPageviews`. + } +} diff --git a/src/services/analytics.ts b/src/services/analytics.ts new file mode 100644 index 00000000..6b3d86a8 --- /dev/null +++ b/src/services/analytics.ts @@ -0,0 +1,902 @@ +import { AppwriteException, Client, type Payload } from '../client'; +import type { Models } from '../models'; + +import { AnalyticsInterval } from '../enums/analytics-interval'; +import { AnalyticsDimension } from '../enums/analytics-dimension'; +export class Analytics { + client: Client; + + constructor(client: Client) { + this.client = client; + } + + /** + * List analytics properties for the current project. + * + * @param {string[]} params.queries - Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of 100 queries are allowed, each 4096 characters long. You may filter on the following attributes: name, domain, timezone, enabled, public, snippetId + * @param {string} params.search - Search term to filter your list results. Matches the property ID, name and domain. Max length: 256 chars. + * @param {boolean} params.total - When set to false, the total count returned will be 0 and will not be calculated. + * @throws {AppwriteException} + * @returns {Promise} + */ + listProperties(params?: { + queries?: string[]; + search?: string; + total?: boolean; + }): Promise; + /** + * List analytics properties for the current project. + * + * @param {string[]} queries - Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of 100 queries are allowed, each 4096 characters long. You may filter on the following attributes: name, domain, timezone, enabled, public, snippetId + * @param {string} search - Search term to filter your list results. Matches the property ID, name and domain. Max length: 256 chars. + * @param {boolean} total - When set to false, the total count returned will be 0 and will not be calculated. + * @throws {AppwriteException} + * @returns {Promise} + * @deprecated Use the object parameter style method for a better developer experience. + */ + listProperties( + queries?: string[], + search?: string, + total?: boolean, + ): Promise; + listProperties( + paramsOrFirst?: + { queries?: string[]; search?: string; total?: boolean } | string[], + ...rest: [string?, boolean?] + ): Promise { + let params: { queries?: string[]; search?: string; total?: boolean }; + + if ( + (typeof paramsOrFirst === 'undefined' && rest.length === 0) || + (paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst)) + ) { + params = (paramsOrFirst || {}) as { + queries?: string[]; + search?: string; + total?: boolean; + }; + } else { + params = { + queries: paramsOrFirst as string[], + search: rest[0] as string, + total: rest[1] as boolean, + }; + } + + const queries = params.queries; + const search = params.search; + const total = params.total; + + const apiPath = '/analytics/properties'; + const apiPayload: Payload = {}; + if (typeof queries !== 'undefined') { + apiPayload['queries'] = queries; + } + if (typeof search !== 'undefined') { + apiPayload['search'] = search; + } + if (typeof total !== 'undefined') { + apiPayload['total'] = total; + } + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + accept: 'application/json', + }; + + return this.client.call('get', uri, apiHeaders, apiPayload); + } + + /** + * Create a new analytics property to track a website or application. + * + * @param {string} params.propertyId - Unique ID. Choose a custom ID or generate a random ID with `ID.unique()`. Max length is 36 chars. + * @param {string} params.name - Human-readable name for this property. + * @param {string} params.domain - Primary domain to track (e.g. example.com). Optional for native apps. + * @param {string} params.timezone - IANA timezone used for daily boundaries. + * @param {boolean} params.enabled - Whether tracking is enabled. + * @param {boolean} params.xpublic - Whether stats are publicly viewable. + * @param {string[]} params.allowedOrigins - Allowed origins for tracking. Use ["*"] to allow all. + * @throws {AppwriteException} + * @returns {Promise} + */ + createProperty(params: { + propertyId: string; + name: string; + domain?: string; + timezone?: string; + enabled?: boolean; + xpublic?: boolean; + allowedOrigins?: string[]; + }): Promise; + /** + * Create a new analytics property to track a website or application. + * + * @param {string} propertyId - Unique ID. Choose a custom ID or generate a random ID with `ID.unique()`. Max length is 36 chars. + * @param {string} name - Human-readable name for this property. + * @param {string} domain - Primary domain to track (e.g. example.com). Optional for native apps. + * @param {string} timezone - IANA timezone used for daily boundaries. + * @param {boolean} enabled - Whether tracking is enabled. + * @param {boolean} xpublic - Whether stats are publicly viewable. + * @param {string[]} allowedOrigins - Allowed origins for tracking. Use ["*"] to allow all. + * @throws {AppwriteException} + * @returns {Promise} + * @deprecated Use the object parameter style method for a better developer experience. + */ + createProperty( + propertyId: string, + name: string, + domain?: string, + timezone?: string, + enabled?: boolean, + xpublic?: boolean, + allowedOrigins?: string[], + ): Promise; + createProperty( + paramsOrFirst: + | { + propertyId: string; + name: string; + domain?: string; + timezone?: string; + enabled?: boolean; + xpublic?: boolean; + allowedOrigins?: string[]; + } + | string, + ...rest: [string?, string?, string?, boolean?, boolean?, string[]?] + ): Promise { + let params: { + propertyId: string; + name: string; + domain?: string; + timezone?: string; + enabled?: boolean; + xpublic?: boolean; + allowedOrigins?: string[]; + }; + + if ( + paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst) + ) { + params = (paramsOrFirst || {}) as { + propertyId: string; + name: string; + domain?: string; + timezone?: string; + enabled?: boolean; + xpublic?: boolean; + allowedOrigins?: string[]; + }; + } else { + params = { + propertyId: paramsOrFirst as string, + name: rest[0] as string, + domain: rest[1] as string, + timezone: rest[2] as string, + enabled: rest[3] as boolean, + xpublic: rest[4] as boolean, + allowedOrigins: rest[5] as string[], + }; + } + + const propertyId = params.propertyId; + const name = params.name; + const domain = params.domain; + const timezone = params.timezone; + const enabled = params.enabled; + const xpublic = params.xpublic; + const allowedOrigins = params.allowedOrigins; + + if (typeof propertyId === 'undefined') { + throw new AppwriteException( + 'Missing required parameter: "propertyId"', + ); + } + if (typeof name === 'undefined') { + throw new AppwriteException('Missing required parameter: "name"'); + } + const apiPath = '/analytics/properties'; + const apiPayload: Payload = {}; + if (typeof propertyId !== 'undefined') { + apiPayload['propertyId'] = propertyId; + } + if (typeof name !== 'undefined') { + apiPayload['name'] = name; + } + if (typeof domain !== 'undefined') { + apiPayload['domain'] = domain; + } + if (typeof timezone !== 'undefined') { + apiPayload['timezone'] = timezone; + } + if (typeof enabled !== 'undefined') { + apiPayload['enabled'] = enabled; + } + if (typeof xpublic !== 'undefined') { + apiPayload['public'] = xpublic; + } + if (typeof allowedOrigins !== 'undefined') { + apiPayload['allowedOrigins'] = allowedOrigins; + } + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + 'content-type': 'application/json', + accept: 'application/json', + }; + + return this.client.call('post', uri, apiHeaders, apiPayload); + } + + /** + * Get an analytics property by ID. + * + * @param {string} params.propertyId - Analytics property unique ID. + * @throws {AppwriteException} + * @returns {Promise} + */ + getProperty(params: { + propertyId: string; + }): Promise; + /** + * Get an analytics property by ID. + * + * @param {string} propertyId - Analytics property unique ID. + * @throws {AppwriteException} + * @returns {Promise} + * @deprecated Use the object parameter style method for a better developer experience. + */ + getProperty(propertyId: string): Promise; + getProperty( + paramsOrFirst: { propertyId: string } | string, + ): Promise { + let params: { propertyId: string }; + + if ( + paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst) + ) { + params = (paramsOrFirst || {}) as { propertyId: string }; + } else { + params = { + propertyId: paramsOrFirst as string, + }; + } + + const propertyId = params.propertyId; + + if (typeof propertyId === 'undefined' || propertyId === '') { + throw new AppwriteException( + 'Missing required parameter: "propertyId"', + ); + } + const apiPath = '/analytics/properties/{propertyId}'.replace( + '{propertyId}', + encodeURIComponent(String(propertyId)), + ); + const apiPayload: Payload = {}; + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + accept: 'application/json', + }; + + return this.client.call('get', uri, apiHeaders, apiPayload); + } + + /** + * Update an analytics property. Only the attributes you pass are changed; omitted attributes keep their current value. + * + * @param {string} params.propertyId - Analytics property unique ID. + * @param {string} params.name - Human-readable name for this property. + * @param {string} params.domain - Primary domain to track (e.g. example.com). Pass an empty string to clear it. + * @param {string} params.timezone - IANA timezone used for daily boundaries. + * @param {boolean} params.enabled - Whether tracking is enabled. + * @param {boolean} params.xpublic - Whether stats are publicly viewable. + * @param {string[]} params.allowedOrigins - Allowed origins for tracking. Use ["*"] to allow all. + * @throws {AppwriteException} + * @returns {Promise} + */ + updateProperty(params: { + propertyId: string; + name?: string; + domain?: string; + timezone?: string; + enabled?: boolean; + xpublic?: boolean; + allowedOrigins?: string[]; + }): Promise; + /** + * Update an analytics property. Only the attributes you pass are changed; omitted attributes keep their current value. + * + * @param {string} propertyId - Analytics property unique ID. + * @param {string} name - Human-readable name for this property. + * @param {string} domain - Primary domain to track (e.g. example.com). Pass an empty string to clear it. + * @param {string} timezone - IANA timezone used for daily boundaries. + * @param {boolean} enabled - Whether tracking is enabled. + * @param {boolean} xpublic - Whether stats are publicly viewable. + * @param {string[]} allowedOrigins - Allowed origins for tracking. Use ["*"] to allow all. + * @throws {AppwriteException} + * @returns {Promise} + * @deprecated Use the object parameter style method for a better developer experience. + */ + updateProperty( + propertyId: string, + name?: string, + domain?: string, + timezone?: string, + enabled?: boolean, + xpublic?: boolean, + allowedOrigins?: string[], + ): Promise; + updateProperty( + paramsOrFirst: + | { + propertyId: string; + name?: string; + domain?: string; + timezone?: string; + enabled?: boolean; + xpublic?: boolean; + allowedOrigins?: string[]; + } + | string, + ...rest: [string?, string?, string?, boolean?, boolean?, string[]?] + ): Promise { + let params: { + propertyId: string; + name?: string; + domain?: string; + timezone?: string; + enabled?: boolean; + xpublic?: boolean; + allowedOrigins?: string[]; + }; + + if ( + paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst) + ) { + params = (paramsOrFirst || {}) as { + propertyId: string; + name?: string; + domain?: string; + timezone?: string; + enabled?: boolean; + xpublic?: boolean; + allowedOrigins?: string[]; + }; + } else { + params = { + propertyId: paramsOrFirst as string, + name: rest[0] as string, + domain: rest[1] as string, + timezone: rest[2] as string, + enabled: rest[3] as boolean, + xpublic: rest[4] as boolean, + allowedOrigins: rest[5] as string[], + }; + } + + const propertyId = params.propertyId; + const name = params.name; + const domain = params.domain; + const timezone = params.timezone; + const enabled = params.enabled; + const xpublic = params.xpublic; + const allowedOrigins = params.allowedOrigins; + + if (typeof propertyId === 'undefined' || propertyId === '') { + throw new AppwriteException( + 'Missing required parameter: "propertyId"', + ); + } + const apiPath = '/analytics/properties/{propertyId}'.replace( + '{propertyId}', + encodeURIComponent(String(propertyId)), + ); + const apiPayload: Payload = {}; + if (typeof name !== 'undefined') { + apiPayload['name'] = name; + } + if (typeof domain !== 'undefined') { + apiPayload['domain'] = domain; + } + if (typeof timezone !== 'undefined') { + apiPayload['timezone'] = timezone; + } + if (typeof enabled !== 'undefined') { + apiPayload['enabled'] = enabled; + } + if (typeof xpublic !== 'undefined') { + apiPayload['public'] = xpublic; + } + if (typeof allowedOrigins !== 'undefined') { + apiPayload['allowedOrigins'] = allowedOrigins; + } + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + 'content-type': 'application/json', + accept: 'application/json', + }; + + return this.client.call('patch', uri, apiHeaders, apiPayload); + } + + /** + * Delete an analytics property along with every event and session collected for it. This cannot be undone. + * + * @param {string} params.propertyId - Analytics property unique ID. + * @throws {AppwriteException} + * @returns {Promise<{}>} + */ + deleteProperty(params: { propertyId: string }): Promise<{}>; + /** + * Delete an analytics property along with every event and session collected for it. This cannot be undone. + * + * @param {string} propertyId - Analytics property unique ID. + * @throws {AppwriteException} + * @returns {Promise<{}>} + * @deprecated Use the object parameter style method for a better developer experience. + */ + deleteProperty(propertyId: string): Promise<{}>; + deleteProperty( + paramsOrFirst: { propertyId: string } | string, + ): Promise<{}> { + let params: { propertyId: string }; + + if ( + paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst) + ) { + params = (paramsOrFirst || {}) as { propertyId: string }; + } else { + params = { + propertyId: paramsOrFirst as string, + }; + } + + const propertyId = params.propertyId; + + if (typeof propertyId === 'undefined' || propertyId === '') { + throw new AppwriteException( + 'Missing required parameter: "propertyId"', + ); + } + const apiPath = '/analytics/properties/{propertyId}'.replace( + '{propertyId}', + encodeURIComponent(String(propertyId)), + ); + const apiPayload: Payload = {}; + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + 'content-type': 'application/json', + accept: 'application/json', + }; + + return this.client.call('delete', uri, apiHeaders, apiPayload); + } + + /** + * Send a tracking event from a browser, native app, or server-side SDK. + * + * @param {string} params.propertyId - Analytics property ID or snippet ID identifying the property. + * @param {string} params.name - Event name. "pageview" is just a conventional event name; events are not modeled specially. + * @param {string} params.url - Full page URL or screen identifier. + * @param {string} params.domain - Hostname (e.g. example.com). + * @param {string} params.referrer - Referrer URL. + * @param {number} params.screenWidth - Viewport width in CSS pixels. + * @param {string} params.sessionHash - Optional session hash (provided by SDK). + * @param {number} params.scrollDepth - Scroll depth percentage 0-100. + * @param {number} params.engagementTime - Engagement time in seconds, 0-4294967295. + * @param {string[]} params.props - Custom string properties as a flat key=value list (max 32 entries, alternating key,value). + * @param {string} params.userId - Override user ID. Requires API-key auth with analytics.write scope. + * @param {string} params.ip - Override IP address. Requires API-key auth with analytics.write scope. + * @param {string} params.userAgent - Override user agent. Requires API-key auth with analytics.write scope. + * @throws {AppwriteException} + * @returns {Promise<{}>} + */ + createEvent(params: { + propertyId: string; + name: string; + url: string; + domain?: string; + referrer?: string; + screenWidth?: number; + sessionHash?: string; + scrollDepth?: number; + engagementTime?: number; + props?: string[]; + userId?: string; + ip?: string; + userAgent?: string; + }): Promise<{}>; + /** + * Send a tracking event from a browser, native app, or server-side SDK. + * + * @param {string} propertyId - Analytics property ID or snippet ID identifying the property. + * @param {string} name - Event name. "pageview" is just a conventional event name; events are not modeled specially. + * @param {string} url - Full page URL or screen identifier. + * @param {string} domain - Hostname (e.g. example.com). + * @param {string} referrer - Referrer URL. + * @param {number} screenWidth - Viewport width in CSS pixels. + * @param {string} sessionHash - Optional session hash (provided by SDK). + * @param {number} scrollDepth - Scroll depth percentage 0-100. + * @param {number} engagementTime - Engagement time in seconds, 0-4294967295. + * @param {string[]} props - Custom string properties as a flat key=value list (max 32 entries, alternating key,value). + * @param {string} userId - Override user ID. Requires API-key auth with analytics.write scope. + * @param {string} ip - Override IP address. Requires API-key auth with analytics.write scope. + * @param {string} userAgent - Override user agent. Requires API-key auth with analytics.write scope. + * @throws {AppwriteException} + * @returns {Promise<{}>} + * @deprecated Use the object parameter style method for a better developer experience. + */ + createEvent( + propertyId: string, + name: string, + url: string, + domain?: string, + referrer?: string, + screenWidth?: number, + sessionHash?: string, + scrollDepth?: number, + engagementTime?: number, + props?: string[], + userId?: string, + ip?: string, + userAgent?: string, + ): Promise<{}>; + createEvent( + paramsOrFirst: + | { + propertyId: string; + name: string; + url: string; + domain?: string; + referrer?: string; + screenWidth?: number; + sessionHash?: string; + scrollDepth?: number; + engagementTime?: number; + props?: string[]; + userId?: string; + ip?: string; + userAgent?: string; + } + | string, + ...rest: [ + string?, + string?, + string?, + string?, + number?, + string?, + number?, + number?, + string[]?, + string?, + string?, + string?, + ] + ): Promise<{}> { + let params: { + propertyId: string; + name: string; + url: string; + domain?: string; + referrer?: string; + screenWidth?: number; + sessionHash?: string; + scrollDepth?: number; + engagementTime?: number; + props?: string[]; + userId?: string; + ip?: string; + userAgent?: string; + }; + + if ( + paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst) + ) { + params = (paramsOrFirst || {}) as { + propertyId: string; + name: string; + url: string; + domain?: string; + referrer?: string; + screenWidth?: number; + sessionHash?: string; + scrollDepth?: number; + engagementTime?: number; + props?: string[]; + userId?: string; + ip?: string; + userAgent?: string; + }; + } else { + params = { + propertyId: paramsOrFirst as string, + name: rest[0] as string, + url: rest[1] as string, + domain: rest[2] as string, + referrer: rest[3] as string, + screenWidth: rest[4] as number, + sessionHash: rest[5] as string, + scrollDepth: rest[6] as number, + engagementTime: rest[7] as number, + props: rest[8] as string[], + userId: rest[9] as string, + ip: rest[10] as string, + userAgent: rest[11] as string, + }; + } + + const propertyId = params.propertyId; + const name = params.name; + const url = params.url; + const domain = params.domain; + const referrer = params.referrer; + const screenWidth = params.screenWidth; + const sessionHash = params.sessionHash; + const scrollDepth = params.scrollDepth; + const engagementTime = params.engagementTime; + const props = params.props; + const userId = params.userId; + const ip = params.ip; + const userAgent = params.userAgent; + + if (typeof propertyId === 'undefined' || propertyId === '') { + throw new AppwriteException( + 'Missing required parameter: "propertyId"', + ); + } + if (typeof name === 'undefined') { + throw new AppwriteException('Missing required parameter: "name"'); + } + if (typeof url === 'undefined') { + throw new AppwriteException('Missing required parameter: "url"'); + } + const apiPath = '/analytics/properties/{propertyId}/events'.replace( + '{propertyId}', + encodeURIComponent(String(propertyId)), + ); + const apiPayload: Payload = {}; + if (typeof name !== 'undefined') { + apiPayload['name'] = name; + } + if (typeof url !== 'undefined') { + apiPayload['url'] = url; + } + if (typeof domain !== 'undefined') { + apiPayload['domain'] = domain; + } + if (typeof referrer !== 'undefined') { + apiPayload['referrer'] = referrer; + } + if (typeof screenWidth !== 'undefined') { + apiPayload['screenWidth'] = screenWidth; + } + if (typeof sessionHash !== 'undefined') { + apiPayload['sessionHash'] = sessionHash; + } + if (typeof scrollDepth !== 'undefined') { + apiPayload['scrollDepth'] = scrollDepth; + } + if (typeof engagementTime !== 'undefined') { + apiPayload['engagementTime'] = engagementTime; + } + if (typeof props !== 'undefined') { + apiPayload['props'] = props; + } + if (typeof userId !== 'undefined') { + apiPayload['userId'] = userId; + } + if (typeof ip !== 'undefined') { + apiPayload['ip'] = ip; + } + if (typeof userAgent !== 'undefined') { + apiPayload['userAgent'] = userAgent; + } + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + 'content-type': 'application/json', + accept: 'application/json', + }; + + return this.client.call('post', uri, apiHeaders, apiPayload); + } + + /** + * Read analytics metrics (visitors, sessions, pageviews, events, bounceRate, …) for a property over a date range. + * + * **Three response shapes**, chosen by `dimensions[]` and `interval`: + * - Neither: one row aggregating the whole window, with `value` and `date` null. Only this shape carries `pageviews`, `visits`, `bounceRate`, `visitDuration`, `viewsPerVisit`, `scrollDepth` and `engagementTime`. + * - `dimensions[]`: one row per dimension value, ranked by visitors, with `value` set and `date` null. + * - `interval`: one row per time bucket in chronological order, with `date` set and `value` null. + * + * Combining `dimensions[]` with `interval` is not supported yet. `queries[]` filters the underlying events using standard Utopia query syntax. + * + * @param {string} params.propertyId - Analytics property unique ID. + * @param {string[]} params.queries - Up to 10 filter queries in Utopia syntax. Allowed attributes: country, region, city, browser, operatingSystem, device, screenSize, referrerSource, channel, utmSource, utmMedium, utmCampaign, utmContent, utmTerm, page, hostname, botName, botCategory, eventName. page, eventName are only accepted alongside `interval`, or with a breakdown on a dimension other than entryPage, exitPage. Allowed methods: equal, notEqual, contains, startsWith, endsWith. Example: `queries[]=equal("country", ["US"])`. + * @param {AnalyticsInterval} params.interval - Time bucket size. Omit (null) for a flat aggregate over the whole window. Allowed: 1h, 1d, 1w, 1m. + * @param {AnalyticsDimension[]} params.dimensions - Dimension to break the metrics down by. One at most for now; the parameter is a list so that cap can be raised without a breaking change. Allowed: country, region, city, browser, operatingSystem, device, screenSize, referrerSource, channel, utmSource, utmMedium, utmCampaign, utmContent, utmTerm, page, hostname, entryPage, exitPage, trafficType, botName, botCategory, eventName. + * @param {string} params.dateRange - Date range shorthand (e.g. 7d, 30d). Ignored for any bound you supply explicitly via startAt/endAt. + * @param {string} params.startAt - Explicit window start in ISO 8601. Defaults to endAt minus dateRange. + * @param {string} params.endAt - Explicit window end in ISO 8601. Defaults to the current time. + * @param {number} params.limit - Maximum number of ranked values to return. + * @throws {AppwriteException} + * @returns {Promise} + */ + listMetrics(params: { + propertyId: string; + queries?: string[]; + interval?: AnalyticsInterval; + dimensions?: AnalyticsDimension[]; + dateRange?: string; + startAt?: string; + endAt?: string; + limit?: number; + }): Promise; + /** + * Read analytics metrics (visitors, sessions, pageviews, events, bounceRate, …) for a property over a date range. + * + * **Three response shapes**, chosen by `dimensions[]` and `interval`: + * - Neither: one row aggregating the whole window, with `value` and `date` null. Only this shape carries `pageviews`, `visits`, `bounceRate`, `visitDuration`, `viewsPerVisit`, `scrollDepth` and `engagementTime`. + * - `dimensions[]`: one row per dimension value, ranked by visitors, with `value` set and `date` null. + * - `interval`: one row per time bucket in chronological order, with `date` set and `value` null. + * + * Combining `dimensions[]` with `interval` is not supported yet. `queries[]` filters the underlying events using standard Utopia query syntax. + * + * @param {string} propertyId - Analytics property unique ID. + * @param {string[]} queries - Up to 10 filter queries in Utopia syntax. Allowed attributes: country, region, city, browser, operatingSystem, device, screenSize, referrerSource, channel, utmSource, utmMedium, utmCampaign, utmContent, utmTerm, page, hostname, botName, botCategory, eventName. page, eventName are only accepted alongside `interval`, or with a breakdown on a dimension other than entryPage, exitPage. Allowed methods: equal, notEqual, contains, startsWith, endsWith. Example: `queries[]=equal("country", ["US"])`. + * @param {AnalyticsInterval} interval - Time bucket size. Omit (null) for a flat aggregate over the whole window. Allowed: 1h, 1d, 1w, 1m. + * @param {AnalyticsDimension[]} dimensions - Dimension to break the metrics down by. One at most for now; the parameter is a list so that cap can be raised without a breaking change. Allowed: country, region, city, browser, operatingSystem, device, screenSize, referrerSource, channel, utmSource, utmMedium, utmCampaign, utmContent, utmTerm, page, hostname, entryPage, exitPage, trafficType, botName, botCategory, eventName. + * @param {string} dateRange - Date range shorthand (e.g. 7d, 30d). Ignored for any bound you supply explicitly via startAt/endAt. + * @param {string} startAt - Explicit window start in ISO 8601. Defaults to endAt minus dateRange. + * @param {string} endAt - Explicit window end in ISO 8601. Defaults to the current time. + * @param {number} limit - Maximum number of ranked values to return. + * @throws {AppwriteException} + * @returns {Promise} + * @deprecated Use the object parameter style method for a better developer experience. + */ + listMetrics( + propertyId: string, + queries?: string[], + interval?: AnalyticsInterval, + dimensions?: AnalyticsDimension[], + dateRange?: string, + startAt?: string, + endAt?: string, + limit?: number, + ): Promise; + listMetrics( + paramsOrFirst: + | { + propertyId: string; + queries?: string[]; + interval?: AnalyticsInterval; + dimensions?: AnalyticsDimension[]; + dateRange?: string; + startAt?: string; + endAt?: string; + limit?: number; + } + | string, + ...rest: [ + string[]?, + AnalyticsInterval?, + AnalyticsDimension[]?, + string?, + string?, + string?, + number?, + ] + ): Promise { + let params: { + propertyId: string; + queries?: string[]; + interval?: AnalyticsInterval; + dimensions?: AnalyticsDimension[]; + dateRange?: string; + startAt?: string; + endAt?: string; + limit?: number; + }; + + if ( + paramsOrFirst && + typeof paramsOrFirst === 'object' && + !Array.isArray(paramsOrFirst) + ) { + params = (paramsOrFirst || {}) as { + propertyId: string; + queries?: string[]; + interval?: AnalyticsInterval; + dimensions?: AnalyticsDimension[]; + dateRange?: string; + startAt?: string; + endAt?: string; + limit?: number; + }; + } else { + params = { + propertyId: paramsOrFirst as string, + queries: rest[0] as string[], + interval: rest[1] as AnalyticsInterval, + dimensions: rest[2] as AnalyticsDimension[], + dateRange: rest[3] as string, + startAt: rest[4] as string, + endAt: rest[5] as string, + limit: rest[6] as number, + }; + } + + const propertyId = params.propertyId; + const queries = params.queries; + const interval = params.interval; + const dimensions = params.dimensions; + const dateRange = params.dateRange; + const startAt = params.startAt; + const endAt = params.endAt; + const limit = params.limit; + + if (typeof propertyId === 'undefined' || propertyId === '') { + throw new AppwriteException( + 'Missing required parameter: "propertyId"', + ); + } + const apiPath = '/analytics/properties/{propertyId}/metrics'.replace( + '{propertyId}', + encodeURIComponent(String(propertyId)), + ); + const apiPayload: Payload = {}; + if (typeof queries !== 'undefined') { + apiPayload['queries'] = queries; + } + if (typeof interval !== 'undefined') { + apiPayload['interval'] = interval; + } + if (typeof dimensions !== 'undefined') { + apiPayload['dimensions'] = dimensions; + } + if (typeof dateRange !== 'undefined') { + apiPayload['dateRange'] = dateRange; + } + if (typeof startAt !== 'undefined') { + apiPayload['startAt'] = startAt; + } + if (typeof endAt !== 'undefined') { + apiPayload['endAt'] = endAt; + } + if (typeof limit !== 'undefined') { + apiPayload['limit'] = limit; + } + const uri = new URL(this.client.config.endpoint + apiPath); + + const apiHeaders: { [header: string]: string } = { + 'X-Appwrite-Project': this.client.config.project, + accept: 'application/json', + }; + + return this.client.call('get', uri, apiHeaders, apiPayload); + } +} diff --git a/src/services/assistant.ts b/src/services/assistant.ts deleted file mode 100644 index a8846bde..00000000 --- a/src/services/assistant.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { AppwriteException, Client, type Payload } from '../client'; - -export class Assistant { - client: Client; - - constructor(client: Client) { - this.client = client; - } - - /** - * Send a prompt to the AI assistant and receive a response. This endpoint allows you to interact with Appwrite's AI assistant by sending questions or prompts and receiving helpful responses in real-time through a server-sent events stream. - * - * @param {string} params.prompt - Prompt. A string containing questions asked to the AI assistant. - * @throws {AppwriteException} - * @returns {Promise} - */ - chat(params: { prompt: string }): Promise; - /** - * Send a prompt to the AI assistant and receive a response. This endpoint allows you to interact with Appwrite's AI assistant by sending questions or prompts and receiving helpful responses in real-time through a server-sent events stream. - * - * @param {string} prompt - Prompt. A string containing questions asked to the AI assistant. - * @throws {AppwriteException} - * @returns {Promise} - * @deprecated Use the object parameter style method for a better developer experience. - */ - chat(prompt: string): Promise; - chat(paramsOrFirst: { prompt: string } | string): Promise { - let params: { prompt: string }; - - if ( - paramsOrFirst && - typeof paramsOrFirst === 'object' && - !Array.isArray(paramsOrFirst) - ) { - params = (paramsOrFirst || {}) as { prompt: string }; - } else { - params = { - prompt: paramsOrFirst as string, - }; - } - - const prompt = params.prompt; - - if (typeof prompt === 'undefined') { - throw new AppwriteException('Missing required parameter: "prompt"'); - } - const apiPath = '/console/assistant'; - const apiPayload: Payload = {}; - if (typeof prompt !== 'undefined') { - apiPayload['prompt'] = prompt; - } - const uri = new URL(this.client.config.endpoint + apiPath); - - const apiHeaders: { [header: string]: string } = { - 'X-Appwrite-Project': this.client.config.project, - 'content-type': 'application/json', - accept: 'text/plain', - }; - - return this.client.call('post', uri, apiHeaders, apiPayload, 'text'); - } -} diff --git a/src/services/avatars.ts b/src/services/avatars.ts index 7242deed..eaec088d 100644 --- a/src/services/avatars.ts +++ b/src/services/avatars.ts @@ -1,4 +1,3 @@ -import { Service } from '../service'; import { AppwriteException, Client, @@ -127,9 +126,7 @@ export class Avatars { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -244,9 +241,7 @@ export class Avatars { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -304,9 +299,7 @@ export class Avatars { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -416,9 +409,7 @@ export class Avatars { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -502,9 +493,7 @@ export class Avatars { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -617,9 +606,7 @@ export class Avatars { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -774,9 +761,7 @@ export class Avatars { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -985,9 +970,7 @@ export class Avatars { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -1312,9 +1295,7 @@ export class Avatars { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } diff --git a/src/services/functions.ts b/src/services/functions.ts index d0bb37d9..1dfcbe7b 100644 --- a/src/services/functions.ts +++ b/src/services/functions.ts @@ -1,4 +1,3 @@ -import { Service } from '../service'; import { AppwriteException, Client, @@ -2190,9 +2189,7 @@ export class Functions { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } diff --git a/src/services/organizations.ts b/src/services/organizations.ts index b0b03447..9276eeb6 100644 --- a/src/services/organizations.ts +++ b/src/services/organizations.ts @@ -1,4 +1,3 @@ -import { Service } from '../service'; import { AppwriteException, Client, type Payload } from '../client'; import type { Models } from '../models'; @@ -2369,9 +2368,7 @@ export class Organizations { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -2631,9 +2628,7 @@ export class Organizations { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } diff --git a/src/services/push.ts b/src/services/push.ts index 7037936b..1c3dd5b3 100644 --- a/src/services/push.ts +++ b/src/services/push.ts @@ -6,7 +6,6 @@ import type { } from 'mqtt'; import { Client } from '../client'; -import { Service } from '../service'; import type { ResolvedTopic, Topic } from '../topic'; /** A message delivered on a subscribed topic (the MQTT analog of a Realtime event). */ @@ -21,6 +20,17 @@ export interface PushMessage { export type MessageCallback = (message: PushMessage) => void | Promise; +/** A background notification the user tapped. */ +export interface PushNotificationOpened { + /** The topic the message was published to. */ + topic: string; + /** The `data` sent with the message (e.g. `createPush`), parsed; `{}` when it had none. */ + data: Record; +} + +// Taps on the notifications this page shows, reported to every onNotificationOpened. +const openedListeners = new Set<(opened: PushNotificationOpened) => void>(); + /** Per-subscription options passed to `subscribe` and `PushSubscription.update`. */ export interface SubscribeOptions { /** @@ -103,7 +113,9 @@ const RECONNECT_PERIOD_MS = 2000; * ); * // sub.update({ background: true }); sub.unsubscribe(); */ -export class Push extends Service { +export class Push { + client: Client; + private mqtt: MqttClient | null = null; private connecting: Promise | null = null; private Buffer: Transport['Buffer'] | null = null; @@ -150,7 +162,7 @@ export class Push extends Service { >(); constructor(client: Client) { - super(client); + this.client = client; } // The broker subscription for a filter uses the highest QoS any local subscription on @@ -188,11 +200,13 @@ export class Push extends Service { * the session, which could belong to a different user), else the session. */ private userTopic(): string { - const { jwt, session } = this.client.config; - const userId = jwt ? userIdFromJwt(jwt) : userIdFromSession(session); + const { jwt } = this.client.config; + const userId = jwt + ? userIdFromJwt(jwt) + : userIdFromSession(clientSession(this.client)); if (!userId) { throw new Error( - 'subscribe() without a topic needs a signed-in user: set a JWT or session on the client', + 'subscribe() without a topic needs a signed-in user: sign in with this client, or set a JWT or session on it', ); } return `users/${userId}`; @@ -470,6 +484,32 @@ export class Push extends Service { return { unsubscribe, update }; } + /** + * The background notification whose tap launched the app, or null. A page shows its + * notifications itself, so a tap only reaches it while it is open (see + * {@link onNotificationOpened}): on the web this always resolves null. + */ + async getInitialNotification(): Promise { + return null; + } + + /** + * Call `callback` each time the user taps a notification this page showed while it is open; + * the tap also focuses the page. Returns a function that stops listening. + * + * ```ts + * const stop = push.onNotificationOpened(({ data }) => openSale(data.saleId)); + * ``` + */ + onNotificationOpened( + callback: (opened: PushNotificationOpened) => void, + ): () => void { + openedListeners.add(callback); + return () => { + openedListeners.delete(callback); + }; + } + /** Tear down the connection and drop all active subscriptions. */ close(): void { if (this.mqtt) { @@ -551,14 +591,15 @@ export class Push extends Service { credential: this.client.config.jwt, }; } - if (this.client.config.session) { + const session = clientSession(this.client); + if (session) { return { authMethod: 'appwrite-session', - credential: this.client.config.session, + credential: session, }; } throw new Error( - 'No credential set on the client; call Client.setJWT() or Client.setSession() first.', + 'No credential: sign in with this client, or call Client.setJWT() or Client.setSession() first.', ); } @@ -816,13 +857,17 @@ export class Push extends Service { payload, qos: packet.qos, }; + const content = notificationContent(message); + const postedTitles = new Set(); for (const sub of this.subscriptions.values()) { if (matches(sub.topic, message.topic)) { await sub.callback(message); - // Notification is per-subscription: only subs that opted in post one, - // each with its own title. - if (sub.background) { - this.notify(message, sub.title); + // Notification is per-subscription: only subs that opted in post one, each + // title once. A title the server sent replaces theirs, so it posts once. + const title = content.title ?? sub.title ?? message.topic; + if (sub.background && !postedTitles.has(title)) { + postedTitles.add(title); + this.notify(message, title, content); } } } @@ -840,29 +885,93 @@ export class Push extends Service { } /** Post a browser notification for a message a background subscription matched. */ - private notify(message: PushMessage, title?: string): void { + private notify( + message: PushMessage, + title: string, + content: NotificationContent, + ): void { if ( typeof Notification === 'undefined' || Notification.permission !== 'granted' ) { return; } + const raw = !content.present; + // `image` is not in the DOM typings, but Chromium-based browsers show it. + const options: NotificationOptions & { image?: string } = { + body: content.body ?? (raw ? message.data : undefined), + image: content.image, + data: { topic: message.topic, payload: message.data }, + }; try { - new Notification(title ?? message.topic, { - body: message.data, - }); + const notification = new Notification(title, options); + notification.onclick = () => { + window.focus(); + notification.close(); + const opened = toOpened(message.topic, message.data); + openedListeners.forEach((listener) => listener(opened)); + }; } catch { // Some browsers only allow notifications from a service worker; ignore. } } } +function toOpened(topic: string, payload: string): PushNotificationOpened { + let data: unknown; + try { + data = (JSON.parse(payload) as { data?: unknown })?.data; + } catch { + data = undefined; + } + return { + topic, + data: + typeof data === 'object' && data !== null && !Array.isArray(data) + ? (data as Record) + : {}, + }; +} + class SupersededError extends Error { constructor() { super('The push connection was superseded'); } } +/** What a message's `notification` block asks a background notification to show. */ +interface NotificationContent { + /** Whether the message has a `notification` block at all. */ + present: boolean; + title?: string; + body?: string; + image?: string; +} + +/** The server's `notification` block in a message; empty when the payload has none or is not JSON. */ +function notificationContent(message: PushMessage): NotificationContent { + let notification: unknown; + try { + notification = (JSON.parse(message.data) as { notification?: unknown }) + ?.notification; + } catch { + return { present: false }; + } + if (typeof notification !== 'object' || notification === null) { + return { present: false }; + } + const field = (name: string): string | undefined => { + const value = (notification as Record)[name]; + return typeof value === 'string' && value !== '' ? value : undefined; + }; + return { + present: true, + title: field('title'), + body: field('body'), + image: field('image'), + }; +} + /** Normalize anything thrown or passed to an error handler into an `Error`. */ function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); @@ -891,6 +1000,25 @@ function userIdFromJwt(jwt?: string): string { } } +/** + * The session set on the client, else the one it signed in with when the browser keeps it in + * localStorage (`cookieFallback`), as realtime reads it. + */ +function clientSession(client: Client): string { + if (client.config.session) { + return client.config.session; + } + try { + const fallback = JSON.parse( + window.localStorage.getItem('cookieFallback') ?? '{}', + ) as Record; + const session = fallback[`a_session_${client.config.project}`]; + return typeof session === 'string' ? session : ''; + } catch { + return ''; + } +} + /** * The user id inside a session secret, which is base64 of JSON `{"id": , "secret": ...}`. * Returns '' when the value is not such a secret. diff --git a/src/services/sites.ts b/src/services/sites.ts index 8f6ae8a3..a0e0429d 100644 --- a/src/services/sites.ts +++ b/src/services/sites.ts @@ -1,4 +1,3 @@ -import { Service } from '../service'; import { AppwriteException, Client, @@ -2206,9 +2205,7 @@ export class Sites { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } diff --git a/src/services/storage.ts b/src/services/storage.ts index 63612948..f035d923 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,4 +1,3 @@ -import { Service } from '../service'; import { AppwriteException, Client, @@ -1191,9 +1190,7 @@ export class Storage { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -1433,9 +1430,7 @@ export class Storage { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); } @@ -1516,9 +1511,7 @@ export class Storage { apiPayload['project'] = this.client.config.project; apiPayload['impersonateuserid'] = this.client.config.impersonateuserid; - for (const [key, value] of Object.entries( - Service.flatten(apiPayload), - )) { + for (const [key, value] of Object.entries(Client.flatten(apiPayload))) { uri.searchParams.append(key, value); }