From d74cfd513aee6620ea67ad51f86dfcd279f3135d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacks=C3=B3n=20Smith?= Date: Fri, 14 Nov 2025 16:58:31 -0500 Subject: [PATCH 001/165] Add AppStoreListing --- .../src/models/AppStoreListing.ts | 83 +++++++++++++++++++ .../brain-service/src/models/Integration.ts | 1 + .../brain-service/src/models/Profile.ts | 6 ++ .../brain-service/src/models/index.ts | 55 ++++++++++++ .../src/types/app-store-listing.ts | 55 ++++++++++++ 5 files changed, 200 insertions(+) create mode 100644 services/learn-card-network/brain-service/src/models/AppStoreListing.ts create mode 100644 services/learn-card-network/brain-service/src/types/app-store-listing.ts diff --git a/services/learn-card-network/brain-service/src/models/AppStoreListing.ts b/services/learn-card-network/brain-service/src/models/AppStoreListing.ts new file mode 100644 index 0000000000..3a6a85b1ba --- /dev/null +++ b/services/learn-card-network/brain-service/src/models/AppStoreListing.ts @@ -0,0 +1,83 @@ +import { ModelFactory, ModelRelatedNodesI, NeogmaInstance } from 'neogma'; + +import { neogma } from '@instance'; + +import { Integration, IntegrationInstance } from './Integration'; +import { Profile, ProfileInstance } from './Profile'; +import { + FlatAppStoreListingType, + AppListingStatus, + LaunchType, + PromotionLevel, +} from 'types/app-store-listing'; + +export type AppStoreListingRelationships = { + publishedBy: ModelRelatedNodesI; + installedBy: ModelRelatedNodesI< + typeof Profile, + ProfileInstance, + { listing_id: string; installed_at: string }, + { listing_id: string; installed_at: string } + >; +}; + +export type AppStoreListingInstance = NeogmaInstance< + FlatAppStoreListingType, + AppStoreListingRelationships +>; + +export const AppStoreListing = ModelFactory< + FlatAppStoreListingType, + AppStoreListingRelationships +>( + { + label: 'AppStoreListing', + schema: { + listing_id: { type: 'string', required: true, uniqueItems: true }, + display_name: { type: 'string', required: true }, + tagline: { type: 'string', required: true }, + full_description: { type: 'string', required: true }, + icon_url: { type: 'string', required: true }, + app_listing_status: { + type: 'string', + enum: AppListingStatus.options, + required: true, + }, + launch_type: { type: 'string', enum: LaunchType.options, required: true }, + launch_config_json: { type: 'string', required: true }, + category: { type: 'string', required: false }, + promo_video_url: { type: 'string', required: false }, + promotion_level: { + type: 'string', + enum: PromotionLevel.options, + required: false, + }, + ios_app_store_id: { type: 'string', required: false }, + android_app_store_id: { type: 'string', required: false }, + privacy_policy_url: { type: 'string', required: false }, + terms_url: { type: 'string', required: false }, + } as any, + relationships: { + publishedBy: { model: Integration, direction: 'in', name: 'PUBLISHES_LISTING' }, + installedBy: { + model: Profile, + direction: 'in', + name: 'INSTALLS', + properties: { + listing_id: { + property: 'listing_id', + schema: { type: 'string', required: true }, + }, + installed_at: { + property: 'installed_at', + schema: { type: 'string', required: true }, + }, + }, + }, + }, + primaryKeyField: 'listing_id', + }, + neogma +); + +export default AppStoreListing; diff --git a/services/learn-card-network/brain-service/src/models/Integration.ts b/services/learn-card-network/brain-service/src/models/Integration.ts index 5c1979470f..7c3f2ed6a3 100644 --- a/services/learn-card-network/brain-service/src/models/Integration.ts +++ b/services/learn-card-network/brain-service/src/models/Integration.ts @@ -14,6 +14,7 @@ export type IntegrationRelationships = { { name: string; did: string; isPrimary?: boolean }, { name: string; did: string; isPrimary?: boolean } >; + publishesListing: ModelRelatedNodesI; }; export type IntegrationInstance = NeogmaInstance; diff --git a/services/learn-card-network/brain-service/src/models/Profile.ts b/services/learn-card-network/brain-service/src/models/Profile.ts index 2fe5b7069a..a8de3d6a79 100644 --- a/services/learn-card-network/brain-service/src/models/Profile.ts +++ b/services/learn-card-network/brain-service/src/models/Profile.ts @@ -46,6 +46,12 @@ export type ProfileRelationships = { { name: string; did: string; isPrimary?: boolean } >; hasContactMethod: ModelRelatedNodesI; + installs: ModelRelatedNodesI< + any, + any, + { listing_id: string; installed_at: string }, + { listing_id: string; installed_at: string } + >; }; export type ProfileInstance = NeogmaInstance; diff --git a/services/learn-card-network/brain-service/src/models/index.ts b/services/learn-card-network/brain-service/src/models/index.ts index 39dbac8953..3bbeab33ee 100644 --- a/services/learn-card-network/brain-service/src/models/index.ts +++ b/services/learn-card-network/brain-service/src/models/index.ts @@ -8,6 +8,8 @@ import { ConsentFlowTransaction } from './ConsentFlowTransaction'; import { SkillFramework } from './SkillFramework'; import { Skill } from './Skill'; import { Tag } from './Tag'; +import { AppStoreListing } from './AppStoreListing'; +import { Integration } from './Integration'; Credential.addRelationships({ credentialReceived: { @@ -55,6 +57,33 @@ Skill.addRelationships({ hasTag: { model: Tag, direction: 'out', name: 'HAS_TAG' }, }); +// App Store relationships +Integration.addRelationships({ + publishesListing: { + model: AppStoreListing, + direction: 'out', + name: 'PUBLISHES_LISTING', + }, +}); + +Profile.addRelationships({ + installs: { + model: AppStoreListing, + direction: 'out', + name: 'INSTALLS', + properties: { + listing_id: { + property: 'listing_id', + schema: { type: 'string', required: true }, + }, + installed_at: { + property: 'installed_at', + schema: { type: 'string', required: true }, + }, + }, + }, +}); + // Use an IIFE to create indices without top-level await (function createIndices() { Promise.all([ @@ -188,6 +217,31 @@ Skill.addRelationships({ 'CREATE INDEX presentation_received_date_idx IF NOT EXISTS FOR ()-[r:PRESENTATION_RECEIVED]-() ON (r.date)' ), + // AppStoreListing indexes + neogma.queryRunner.run( + 'CREATE INDEX app_store_listing_id_idx IF NOT EXISTS FOR (a:AppStoreListing) ON (a.listing_id)' + ), + neogma.queryRunner.run( + 'CREATE INDEX app_store_listing_status_idx IF NOT EXISTS FOR (a:AppStoreListing) ON (a.app_listing_status)' + ), + neogma.queryRunner.run( + 'CREATE INDEX app_store_listing_category_idx IF NOT EXISTS FOR (a:AppStoreListing) ON (a.category)' + ), + neogma.queryRunner.run( + 'CREATE INDEX app_store_listing_promotion_idx IF NOT EXISTS FOR (a:AppStoreListing) ON (a.promotion_level)' + ), + neogma.queryRunner.run( + 'CREATE TEXT INDEX app_store_listing_name_text_idx IF NOT EXISTS FOR (a:AppStoreListing) ON (a.display_name)' + ), + + // Relationship property indexes for AppStoreListing + neogma.queryRunner.run( + 'CREATE INDEX installs_listing_id_idx IF NOT EXISTS FOR ()-[r:INSTALLS]-() ON (r.listing_id)' + ), + neogma.queryRunner.run( + 'CREATE INDEX installs_installed_at_idx IF NOT EXISTS FOR ()-[r:INSTALLS]-() ON (r.installed_at)' + ), + // Constraints neogma.queryRunner.run( 'CREATE CONSTRAINT contact_method_type_value_unique IF NOT EXISTS FOR (c:ContactMethod) REQUIRE (c.type, c.value) IS UNIQUE' @@ -220,3 +274,4 @@ export * from './SkillFramework'; export * from './Skill'; export * from './Tag'; export * from './Integration'; +export * from './AppStoreListing'; diff --git a/services/learn-card-network/brain-service/src/types/app-store-listing.ts b/services/learn-card-network/brain-service/src/types/app-store-listing.ts new file mode 100644 index 0000000000..692f4531fc --- /dev/null +++ b/services/learn-card-network/brain-service/src/types/app-store-listing.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +export const AppListingStatus = z.enum(['DRAFT', 'PENDING_REVIEW', 'LISTED', 'ARCHIVED']); +export type AppListingStatusEnum = z.infer; + +export const LaunchType = z.enum([ + 'EMBEDDED_IFRAME', + 'SECOND_SCREEN', + 'DIRECT_LINK', + 'CONSENT_REDIRECT', + 'SERVER_HEADLESS', +]); +export type LaunchTypeEnum = z.infer; + +export const PromotionLevel = z.enum([ + 'FEATURED_CAROUSEL', + 'CURATED_LIST', + 'STANDARD', + 'DEMOTED', +]); +export type PromotionLevelEnum = z.infer; + +export const AppStoreListingValidator = z.object({ + listing_id: z.string(), + display_name: z.string(), + tagline: z.string(), + full_description: z.string(), + icon_url: z.string(), + app_listing_status: AppListingStatus, + launch_type: LaunchType, + launch_config_json: z.string(), + category: z.string().optional(), + promo_video_url: z.string().optional(), + promotion_level: PromotionLevel.optional(), + ios_app_store_id: z.string().optional(), + android_app_store_id: z.string().optional(), + privacy_policy_url: z.string().optional(), + terms_url: z.string().optional(), +}); +export type AppStoreListingType = z.infer; + +export const FlatAppStoreListingValidator = AppStoreListingValidator.catchall(z.any()); +export type FlatAppStoreListingType = z.infer; + +export const AppStoreListingCreateValidator = AppStoreListingValidator.omit({ + listing_id: true, +}).extend({ + listing_id: z.string().optional(), +}); +export type AppStoreListingCreateType = z.infer; + +export const AppStoreListingUpdateValidator = AppStoreListingValidator.partial().extend({ + listing_id: z.string(), +}); +export type AppStoreListingUpdateType = z.infer; From a842aaebac9b7ff1dfdd80dbc3465986ce229980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacks=C3=B3n=20Smith?= Date: Mon, 24 Nov 2025 15:56:48 -0500 Subject: [PATCH 002/165] Add accesslayer for app store listing and tests --- .../accesslayer/app-store-listing/create.ts | 39 ++ .../accesslayer/app-store-listing/delete.ts | 5 + .../src/accesslayer/app-store-listing/read.ts | 179 ++++++ .../app-store-listing/relationships/create.ts | 36 ++ .../app-store-listing/relationships/delete.ts | 23 + .../app-store-listing/relationships/read.ts | 55 ++ .../accesslayer/app-store-listing/update.ts | 56 ++ .../src/types/app-store-listing.ts | 4 +- .../test/app-store-listing.spec.ts | 550 ++++++++++++++++++ 9 files changed, 945 insertions(+), 2 deletions(-) create mode 100644 services/learn-card-network/brain-service/src/accesslayer/app-store-listing/create.ts create mode 100644 services/learn-card-network/brain-service/src/accesslayer/app-store-listing/delete.ts create mode 100644 services/learn-card-network/brain-service/src/accesslayer/app-store-listing/read.ts create mode 100644 services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/create.ts create mode 100644 services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/delete.ts create mode 100644 services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/read.ts create mode 100644 services/learn-card-network/brain-service/src/accesslayer/app-store-listing/update.ts create mode 100644 services/learn-card-network/brain-service/test/app-store-listing.spec.ts diff --git a/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/create.ts b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/create.ts new file mode 100644 index 0000000000..95c27ce3b0 --- /dev/null +++ b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/create.ts @@ -0,0 +1,39 @@ +import { v4 as uuid } from 'uuid'; +import { BindParam, QueryBuilder } from 'neogma'; + +import { flattenObject, inflateObject } from '@helpers/objects.helpers'; +import { AppStoreListing } from '@models'; +import { AppStoreListingCreateType, AppStoreListingType } from 'types/app-store-listing'; + +export const createAppStoreListing = async ( + input: AppStoreListingCreateType +): Promise => { + const params = flattenObject({ + listing_id: input.listing_id ?? uuid(), + display_name: input.display_name, + tagline: input.tagline, + full_description: input.full_description, + icon_url: input.icon_url, + app_listing_status: input.app_listing_status, + launch_type: input.launch_type, + launch_config_json: input.launch_config_json, + category: input.category, + promo_video_url: input.promo_video_url, + promotion_level: input.promotion_level, + ios_app_store_id: input.ios_app_store_id, + android_app_store_id: input.android_app_store_id, + privacy_policy_url: input.privacy_policy_url, + terms_url: input.terms_url, + }); + + const result = await new QueryBuilder(new BindParam({ params })) + .create({ model: AppStoreListing, identifier: 'listing' }) + .set('listing += $params') + .return('listing') + .limit(1) + .run(); + + const listing = result.records[0]?.get('listing').properties!; + + return (inflateObject as any)(listing); +}; diff --git a/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/delete.ts b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/delete.ts new file mode 100644 index 0000000000..af6af5124b --- /dev/null +++ b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/delete.ts @@ -0,0 +1,5 @@ +import { AppStoreListing } from '@models'; + +export const deleteAppStoreListing = async (listingId: string): Promise => { + await AppStoreListing.delete({ detach: true, where: { listing_id: listingId } }); +}; diff --git a/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/read.ts b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/read.ts new file mode 100644 index 0000000000..ded46f2be2 --- /dev/null +++ b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/read.ts @@ -0,0 +1,179 @@ +import { BindParam, QueryBuilder } from 'neogma'; +import { int } from 'neo4j-driver'; + +import { inflateObject } from '@helpers/objects.helpers'; +import { AppStoreListing } from '@models'; +import { AppStoreListingType } from 'types/app-store-listing'; +import { neogma } from '@instance'; + +export const readAppStoreListingById = async ( + listingId: string +): Promise => { + const result = await new QueryBuilder(new BindParam({ listing_id: listingId })) + .match({ model: AppStoreListing, identifier: 'listing', where: { listing_id: listingId } }) + .return('listing') + .limit(1) + .run(); + + const listing = result.records[0]?.get('listing')?.properties; + + if (!listing) return null; + + return inflateObject(listing as any); +}; + +export const getListingsForIntegration = async ( + integrationId: string, + { limit, cursor }: { limit: number; cursor?: string } +): Promise => { + const result = await neogma.queryRunner.run( + `MATCH (i:Integration {id: $integrationId})-[:PUBLISHES_LISTING]->(listing:AppStoreListing) + ${cursor ? 'WHERE listing.listing_id < $cursor' : ''} + RETURN DISTINCT listing + ORDER BY listing.listing_id DESC + LIMIT $limit`, + { + integrationId, + cursor: cursor ?? null, + limit: int(limit), + } + ); + + return result.records.map(record => { + const listing = record.get('listing')?.properties; + return inflateObject(listing as any); + }); +}; + +export const countListingsForIntegration = async (integrationId: string): Promise => { + const result = await neogma.queryRunner.run( + `MATCH (i:Integration {id: $integrationId})-[:PUBLISHES_LISTING]->(listing:AppStoreListing) + RETURN COUNT(DISTINCT listing) AS count`, + { integrationId } + ); + + return Number(result.records[0]?.get('count') ?? 0); +}; + +export const getListedApps = async ( + { + limit, + cursor, + category, + promotionLevel, + }: { + limit: number; + cursor?: string; + category?: string; + promotionLevel?: string; + } +): Promise => { + const whereClauses: string[] = ["listing.app_listing_status = 'LISTED'"]; + const params: Record = { limit: int(limit) }; + + if (cursor) { + whereClauses.push('listing.listing_id < $cursor'); + params.cursor = cursor; + } + + if (category) { + whereClauses.push('listing.category = $category'); + params.category = category; + } + + if (promotionLevel) { + whereClauses.push('listing.promotion_level = $promotionLevel'); + params.promotionLevel = promotionLevel; + } + + const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''; + + const result = await neogma.queryRunner.run( + `MATCH (listing:AppStoreListing) + ${whereClause} + RETURN listing + ORDER BY listing.promotion_level ASC, listing.listing_id DESC + LIMIT $limit`, + params + ); + + return result.records.map(record => { + const listing = record.get('listing')?.properties; + return inflateObject(listing as any); + }); +}; + +export const searchAppStoreListings = async ( + searchTerm: string, + { limit, cursor }: { limit: number; cursor?: string } +): Promise => { + const result = await neogma.queryRunner.run( + `CALL db.index.fulltext.queryNodes('app_store_listing_name_text_idx', $searchTerm) + YIELD node, score + WHERE node.app_listing_status = 'LISTED' + ${cursor ? 'AND node.listing_id < $cursor' : ''} + RETURN node AS listing + ORDER BY score DESC, node.listing_id DESC + LIMIT $limit`, + { + searchTerm, + cursor: cursor ?? null, + limit: int(limit), + } + ); + + return result.records.map(record => { + const listing = record.get('listing')?.properties; + return inflateObject(listing as any); + }); +}; + +export const getInstalledAppsForProfile = async ( + profileId: string, + { limit, cursor }: { limit: number; cursor?: string } +): Promise> => { + const result = await neogma.queryRunner.run( + `MATCH (p:Profile {profileId: $profileId})-[r:INSTALLS]->(listing:AppStoreListing) + ${cursor ? 'WHERE r.installed_at < $cursor' : ''} + RETURN listing, r.installed_at AS installed_at + ORDER BY r.installed_at DESC, listing.listing_id DESC + LIMIT $limit`, + { + profileId, + cursor: cursor ?? null, + limit: int(limit), + } + ); + + return result.records.map(record => { + const listing = record.get('listing')?.properties; + const installed_at = record.get('installed_at'); + return { + ...inflateObject(listing as any), + installed_at, + }; + }); +}; + +export const countInstalledAppsForProfile = async (profileId: string): Promise => { + const result = await neogma.queryRunner.run( + `MATCH (p:Profile {profileId: $profileId})-[:INSTALLS]->(listing:AppStoreListing) + RETURN COUNT(DISTINCT listing) AS count`, + { profileId } + ); + + return Number(result.records[0]?.get('count') ?? 0); +}; + +export const checkIfProfileInstalledApp = async ( + profileId: string, + listingId: string +): Promise => { + const result = await neogma.queryRunner.run( + `MATCH (p:Profile {profileId: $profileId})-[:INSTALLS]->(listing:AppStoreListing {listing_id: $listingId}) + RETURN COUNT(listing) > 0 AS installed`, + { profileId, listingId } + ); + + return result.records[0]?.get('installed') ?? false; +}; diff --git a/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/create.ts b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/create.ts new file mode 100644 index 0000000000..601e0e4e1e --- /dev/null +++ b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/create.ts @@ -0,0 +1,36 @@ +import { AppStoreListing } from '@models'; + +export const associateListingWithIntegration = async ( + listingId: string, + integrationId: string +): Promise => { + await AppStoreListing.relateTo({ + alias: 'publishedBy', + where: { + source: { listing_id: listingId }, + target: { id: integrationId }, + }, + }); + + return true; +}; + +export const installAppForProfile = async ( + profileId: string, + listingId: string, + installedAt: string = new Date().toISOString() +): Promise => { + await AppStoreListing.relateTo({ + alias: 'installedBy', + where: { + source: { listing_id: listingId }, + target: { profileId }, + }, + properties: { + listing_id: listingId, + installed_at: installedAt, + }, + }); + + return true; +}; diff --git a/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/delete.ts b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/delete.ts new file mode 100644 index 0000000000..ef089f435c --- /dev/null +++ b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/delete.ts @@ -0,0 +1,23 @@ +import { neogma } from '@instance'; + +export const dissociateListingFromIntegration = async ( + listingId: string, + integrationId: string +): Promise => { + await neogma.queryRunner.run( + `MATCH (i:Integration {id: $integrationId})-[r:PUBLISHES_LISTING]->(listing:AppStoreListing {listing_id: $listingId}) + DELETE r`, + { integrationId, listingId } + ); +}; + +export const uninstallAppForProfile = async ( + profileId: string, + listingId: string +): Promise => { + await neogma.queryRunner.run( + `MATCH (p:Profile {profileId: $profileId})-[r:INSTALLS]->(listing:AppStoreListing {listing_id: $listingId}) + DELETE r`, + { profileId, listingId } + ); +}; diff --git a/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/read.ts b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/read.ts new file mode 100644 index 0000000000..4c7546cd2a --- /dev/null +++ b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/relationships/read.ts @@ -0,0 +1,55 @@ +import { neogma } from '@instance'; +import { inflateObject } from '@helpers/objects.helpers'; +import { IntegrationType } from 'types/integration'; +import { ProfileType } from 'types/profile'; +import { int } from 'neo4j-driver'; + +export const getIntegrationForListing = async ( + listingId: string +): Promise => { + const result = await neogma.queryRunner.run( + `MATCH (i:Integration)-[:PUBLISHES_LISTING]->(listing:AppStoreListing {listing_id: $listingId}) + RETURN i AS integration + LIMIT 1`, + { listingId } + ); + + const integration = result.records[0]?.get('integration')?.properties; + + if (!integration) return null; + + return inflateObject(integration as any); +}; + +export const getProfilesInstalledApp = async ( + listingId: string, + { limit, cursor }: { limit: number; cursor?: string } +): Promise => { + const result = await neogma.queryRunner.run( + `MATCH (p:Profile)-[:INSTALLS]->(listing:AppStoreListing {listing_id: $listingId}) + ${cursor ? 'WHERE p.profileId < $cursor' : ''} + RETURN p AS profile + ORDER BY p.profileId DESC + LIMIT $limit`, + { + listingId, + cursor: cursor ?? null, + limit: int(limit), + } + ); + + return result.records.map(record => { + const profile = record.get('profile')?.properties; + return inflateObject(profile as any); + }); +}; + +export const countProfilesInstalledApp = async (listingId: string): Promise => { + const result = await neogma.queryRunner.run( + `MATCH (p:Profile)-[:INSTALLS]->(listing:AppStoreListing {listing_id: $listingId}) + RETURN COUNT(DISTINCT p) AS count`, + { listingId } + ); + + return Number(result.records[0]?.get('count') ?? 0); +}; diff --git a/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/update.ts b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/update.ts new file mode 100644 index 0000000000..318e684e20 --- /dev/null +++ b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/update.ts @@ -0,0 +1,56 @@ +import { QueryBuilder, BindParam } from 'neogma'; + +import { AppStoreListing } from '@models'; +import { + FlatAppStoreListingType, + AppStoreListingType, + AppStoreListingUpdateType, +} from 'types/app-store-listing'; + +export const updateAppStoreListing = async ( + listing: AppStoreListingType, + updates: AppStoreListingUpdateType +): Promise => { + const updatesToPersist: Partial = {}; + + if (typeof updates.display_name !== 'undefined') + updatesToPersist.display_name = updates.display_name; + if (typeof updates.tagline !== 'undefined') updatesToPersist.tagline = updates.tagline; + if (typeof updates.full_description !== 'undefined') + updatesToPersist.full_description = updates.full_description; + if (typeof updates.icon_url !== 'undefined') updatesToPersist.icon_url = updates.icon_url; + if (typeof updates.app_listing_status !== 'undefined') + updatesToPersist.app_listing_status = updates.app_listing_status; + if (typeof updates.launch_type !== 'undefined') + updatesToPersist.launch_type = updates.launch_type; + if (typeof updates.launch_config_json !== 'undefined') + updatesToPersist.launch_config_json = updates.launch_config_json; + if (typeof updates.category !== 'undefined') updatesToPersist.category = updates.category; + if (typeof updates.promo_video_url !== 'undefined') + updatesToPersist.promo_video_url = updates.promo_video_url; + if (typeof updates.promotion_level !== 'undefined') + updatesToPersist.promotion_level = updates.promotion_level; + if (typeof updates.ios_app_store_id !== 'undefined') + updatesToPersist.ios_app_store_id = updates.ios_app_store_id; + if (typeof updates.android_app_store_id !== 'undefined') + updatesToPersist.android_app_store_id = updates.android_app_store_id; + if (typeof updates.privacy_policy_url !== 'undefined') + updatesToPersist.privacy_policy_url = updates.privacy_policy_url; + if (typeof updates.terms_url !== 'undefined') updatesToPersist.terms_url = updates.terms_url; + + const params: Partial = updatesToPersist; + + // Nothing to update + if (Object.keys(params as Record).length === 0) return true; + + const result = await new QueryBuilder(new BindParam({ params })) + .match({ + model: AppStoreListing, + where: { listing_id: listing.listing_id }, + identifier: 'listing', + }) + .set('listing += $params') + .run(); + + return result.summary.updateStatistics.containsUpdates(); +}; diff --git a/services/learn-card-network/brain-service/src/types/app-store-listing.ts b/services/learn-card-network/brain-service/src/types/app-store-listing.ts index 692f4531fc..2e707d5d57 100644 --- a/services/learn-card-network/brain-service/src/types/app-store-listing.ts +++ b/services/learn-card-network/brain-service/src/types/app-store-listing.ts @@ -49,7 +49,7 @@ export const AppStoreListingCreateValidator = AppStoreListingValidator.omit({ }); export type AppStoreListingCreateType = z.infer; -export const AppStoreListingUpdateValidator = AppStoreListingValidator.partial().extend({ - listing_id: z.string(), +export const AppStoreListingUpdateValidator = AppStoreListingValidator.partial().omit({ + listing_id: true, }); export type AppStoreListingUpdateType = z.infer; diff --git a/services/learn-card-network/brain-service/test/app-store-listing.spec.ts b/services/learn-card-network/brain-service/test/app-store-listing.spec.ts new file mode 100644 index 0000000000..49fc5fe71a --- /dev/null +++ b/services/learn-card-network/brain-service/test/app-store-listing.spec.ts @@ -0,0 +1,550 @@ +import { describe, it, beforeAll, beforeEach, afterAll, expect } from 'vitest'; + +import { getUser } from './helpers/getClient'; + +import { AppStoreListing, Integration, Profile } from '@models'; + +import { createAppStoreListing } from '@accesslayer/app-store-listing/create'; +import { + readAppStoreListingById, + getListingsForIntegration, + countListingsForIntegration, + getListedApps, + getInstalledAppsForProfile, + countInstalledAppsForProfile, + checkIfProfileInstalledApp, +} from '@accesslayer/app-store-listing/read'; +import { updateAppStoreListing } from '@accesslayer/app-store-listing/update'; +import { deleteAppStoreListing } from '@accesslayer/app-store-listing/delete'; +import { + associateListingWithIntegration, + installAppForProfile, +} from '@accesslayer/app-store-listing/relationships/create'; +import { + dissociateListingFromIntegration, + uninstallAppForProfile, +} from '@accesslayer/app-store-listing/relationships/delete'; +import { + getIntegrationForListing, + getProfilesInstalledApp, + countProfilesInstalledApp, +} from '@accesslayer/app-store-listing/relationships/read'; +import { createIntegration } from '@accesslayer/integration/create'; +import { associateIntegrationWithProfile } from '@accesslayer/integration/relationships/create'; + +let userA: Awaited>; +let userB: Awaited>; +let userC: Awaited>; + +const makeListingInput = (overrides?: Record) => ({ + display_name: 'Test App', + tagline: 'A test application', + full_description: 'This is a comprehensive test application for the app store', + icon_url: 'https://example.com/icon.png', + app_listing_status: 'DRAFT' as const, + launch_type: 'EMBEDDED_IFRAME' as const, + launch_config_json: JSON.stringify({ iframeUrl: 'https://app.example.com' }), + category: 'Learning', + promotion_level: 'STANDARD' as const, + ...overrides, +}); + +const seedProfile = async (user: Awaited>, profileId: string) => { + await user.clients.fullAuth.profile.createProfile({ profileId }); +}; + +const seedIntegration = async (name: string, profileId: string) => { + const integration = await createIntegration({ + name, + description: `Test integration for ${name}`, + whitelistedDomains: ['example.com'], + }); + await associateIntegrationWithProfile(integration.id, profileId); + return integration; +}; + +describe('AppStoreListing', () => { + beforeAll(async () => { + userA = await getUser('a'.repeat(64)); + userB = await getUser('b'.repeat(64)); + userC = await getUser('c'.repeat(64)); + }); + + describe('Access Layer', () => { + beforeEach(async () => { + await AppStoreListing.delete({ detach: true, where: {} }); + await Integration.delete({ detach: true, where: {} }); + await Profile.delete({ detach: true, where: {} }); + + await seedProfile(userA, 'usera'); + await seedProfile(userB, 'userb'); + await seedProfile(userC, 'userc'); + }); + + afterAll(async () => { + await AppStoreListing.delete({ detach: true, where: {} }); + await Integration.delete({ detach: true, where: {} }); + await Profile.delete({ detach: true, where: {} }); + }); + + describe('CRUD Operations', () => { + it('create, read by id, update, and delete', async () => { + // Create + const created = await createAppStoreListing( + makeListingInput({ display_name: 'My App' }) + ); + + expect(created.listing_id).toBeTruthy(); + expect(created.display_name).toBe('My App'); + expect(created.tagline).toBe('A test application'); + expect(created.app_listing_status).toBe('DRAFT'); + expect(created.launch_type).toBe('EMBEDDED_IFRAME'); + expect(created.category).toBe('Learning'); + expect(created.promotion_level).toBe('STANDARD'); + + // Read by ID + const byId = await readAppStoreListingById(created.listing_id); + expect(byId?.display_name).toBe('My App'); + expect(byId?.listing_id).toBe(created.listing_id); + + // Update + const updated = await updateAppStoreListing(created, { + display_name: 'Updated App', + app_listing_status: 'LISTED', + promotion_level: 'FEATURED_CAROUSEL', + }); + expect(updated).toBe(true); + + const afterUpdate = await readAppStoreListingById(created.listing_id); + expect(afterUpdate?.display_name).toBe('Updated App'); + expect(afterUpdate?.app_listing_status).toBe('LISTED'); + expect(afterUpdate?.promotion_level).toBe('FEATURED_CAROUSEL'); + expect(afterUpdate?.tagline).toBe('A test application'); // unchanged + + // Delete + await deleteAppStoreListing(created.listing_id); + const afterDelete = await readAppStoreListingById(created.listing_id); + expect(afterDelete).toBeNull(); + }); + + it('creates listing with optional fields', async () => { + const listing = await createAppStoreListing( + makeListingInput({ + promo_video_url: 'https://example.com/video.mp4', + ios_app_store_id: 'com.example.app', + android_app_store_id: 'com.example.app.android', + privacy_policy_url: 'https://example.com/privacy', + terms_url: 'https://example.com/terms', + }) + ); + + expect(listing.promo_video_url).toBe('https://example.com/video.mp4'); + expect(listing.ios_app_store_id).toBe('com.example.app'); + expect(listing.android_app_store_id).toBe('com.example.app.android'); + expect(listing.privacy_policy_url).toBe('https://example.com/privacy'); + expect(listing.terms_url).toBe('https://example.com/terms'); + }); + + it('creates listing with custom listing_id', async () => { + const customId = 'custom-listing-123'; + const listing = await createAppStoreListing( + makeListingInput({ listing_id: customId }) + ); + + expect(listing.listing_id).toBe(customId); + }); + + it('update returns false when no fields change', async () => { + const listing = await createAppStoreListing(makeListingInput()); + const result = await updateAppStoreListing(listing, {}); + expect(result).toBe(true); // nothing to update + }); + }); + + describe('Integration Relationships', () => { + it('associates listing with integration and retrieves it', async () => { + const integration = await seedIntegration('Test Integration', 'usera'); + const listing = await createAppStoreListing( + makeListingInput({ display_name: 'Integration App' }) + ); + + // Associate + await associateListingWithIntegration(listing.listing_id, integration.id); + + // Read integration for listing + const fetchedIntegration = await getIntegrationForListing(listing.listing_id); + expect(fetchedIntegration?.id).toBe(integration.id); + expect(fetchedIntegration?.name).toBe('Test Integration'); + + // Get listings for integration + const listings = await getListingsForIntegration(integration.id, { limit: 10 }); + expect(listings.length).toBe(1); + expect(listings[0]?.listing_id).toBe(listing.listing_id); + + // Count listings + const count = await countListingsForIntegration(integration.id); + expect(count).toBe(1); + }); + + it('dissociates listing from integration', async () => { + const integration = await seedIntegration('Test Integration', 'usera'); + const listing = await createAppStoreListing(makeListingInput()); + + await associateListingWithIntegration(listing.listing_id, integration.id); + expect(await getIntegrationForListing(listing.listing_id)).toBeTruthy(); + + await dissociateListingFromIntegration(listing.listing_id, integration.id); + expect(await getIntegrationForListing(listing.listing_id)).toBeNull(); + }); + + it('paginates listings for integration', async () => { + const integration = await seedIntegration('Test Integration', 'usera'); + const listing1 = await createAppStoreListing( + makeListingInput({ display_name: 'App 1' }) + ); + const listing2 = await createAppStoreListing( + makeListingInput({ display_name: 'App 2' }) + ); + const listing3 = await createAppStoreListing( + makeListingInput({ display_name: 'App 3' }) + ); + + await associateListingWithIntegration(listing1.listing_id, integration.id); + await associateListingWithIntegration(listing2.listing_id, integration.id); + await associateListingWithIntegration(listing3.listing_id, integration.id); + + const page1 = await getListingsForIntegration(integration.id, { limit: 2 }); + expect(page1.length).toBe(2); + + const page2 = await getListingsForIntegration(integration.id, { + limit: 2, + cursor: page1[1]?.listing_id, + }); + expect(page2.length).toBe(1); + }); + }); + + describe('Profile Install Relationships', () => { + it('installs app for profile and checks installation', async () => { + const listing = await createAppStoreListing( + makeListingInput({ display_name: 'Installable App' }) + ); + + // Check not installed initially + const isInstalledBefore = await checkIfProfileInstalledApp( + 'usera', + listing.listing_id + ); + expect(isInstalledBefore).toBe(false); + + // Install + const installedAt = new Date().toISOString(); + await installAppForProfile('usera', listing.listing_id, installedAt); + + // Check installed + const isInstalledAfter = await checkIfProfileInstalledApp( + 'usera', + listing.listing_id + ); + expect(isInstalledAfter).toBe(true); + + // Get installed apps for profile + const installed = await getInstalledAppsForProfile('usera', { limit: 10 }); + expect(installed.length).toBe(1); + expect(installed[0]?.listing_id).toBe(listing.listing_id); + expect(installed[0]?.installed_at).toBeTruthy(); + + // Count + const count = await countInstalledAppsForProfile('usera'); + expect(count).toBe(1); + }); + + it('uninstalls app for profile', async () => { + const listing = await createAppStoreListing(makeListingInput()); + await installAppForProfile('usera', listing.listing_id); + + expect(await checkIfProfileInstalledApp('usera', listing.listing_id)).toBe(true); + + await uninstallAppForProfile('usera', listing.listing_id); + + expect(await checkIfProfileInstalledApp('usera', listing.listing_id)).toBe(false); + }); + + it('gets profiles who installed an app', async () => { + const listing = await createAppStoreListing(makeListingInput()); + + await installAppForProfile('usera', listing.listing_id); + await installAppForProfile('userb', listing.listing_id); + + const profiles = await getProfilesInstalledApp(listing.listing_id, { limit: 10 }); + expect(profiles.length).toBe(2); + + const count = await countProfilesInstalledApp(listing.listing_id); + expect(count).toBe(2); + }); + + it('paginates installed apps for profile', async () => { + const listing1 = await createAppStoreListing( + makeListingInput({ display_name: 'App 1' }) + ); + const listing2 = await createAppStoreListing( + makeListingInput({ display_name: 'App 2' }) + ); + const listing3 = await createAppStoreListing( + makeListingInput({ display_name: 'App 3' }) + ); + + await installAppForProfile('usera', listing1.listing_id, '2025-01-01T00:00:00Z'); + await installAppForProfile('usera', listing2.listing_id, '2025-01-02T00:00:00Z'); + await installAppForProfile('usera', listing3.listing_id, '2025-01-03T00:00:00Z'); + + const page1 = await getInstalledAppsForProfile('usera', { limit: 2 }); + expect(page1.length).toBe(2); + + // Use installed_at as cursor since results are ordered by installed_at DESC + const page2 = await getInstalledAppsForProfile('usera', { + limit: 2, + cursor: page1[1]?.installed_at, + }); + expect(page2.length).toBe(1); + }); + + it('stores listing_id in INSTALLS relationship', async () => { + const listing = await createAppStoreListing(makeListingInput()); + await installAppForProfile('usera', listing.listing_id); + + const installed = await getInstalledAppsForProfile('usera', { limit: 10 }); + expect(installed[0]?.listing_id).toBe(listing.listing_id); + }); + }); + + describe('App Store Queries', () => { + it('filters listed apps by status', async () => { + await createAppStoreListing( + makeListingInput({ + display_name: 'Draft App', + app_listing_status: 'DRAFT', + }) + ); + await createAppStoreListing( + makeListingInput({ + display_name: 'Listed App', + app_listing_status: 'LISTED', + }) + ); + await createAppStoreListing( + makeListingInput({ + display_name: 'Archived App', + app_listing_status: 'ARCHIVED', + }) + ); + + const listedApps = await getListedApps({ limit: 10 }); + expect(listedApps.length).toBe(1); + expect(listedApps[0]?.display_name).toBe('Listed App'); + }); + + it('filters listed apps by category', async () => { + await createAppStoreListing( + makeListingInput({ + display_name: 'Learning App', + app_listing_status: 'LISTED', + category: 'Learning', + }) + ); + await createAppStoreListing( + makeListingInput({ + display_name: 'Games App', + app_listing_status: 'LISTED', + category: 'Games', + }) + ); + + const learningApps = await getListedApps({ limit: 10, category: 'Learning' }); + expect(learningApps.length).toBe(1); + expect(learningApps[0]?.display_name).toBe('Learning App'); + + const gamesApps = await getListedApps({ limit: 10, category: 'Games' }); + expect(gamesApps.length).toBe(1); + expect(gamesApps[0]?.display_name).toBe('Games App'); + }); + + it('filters listed apps by promotion level', async () => { + await createAppStoreListing( + makeListingInput({ + display_name: 'Featured App', + app_listing_status: 'LISTED', + promotion_level: 'FEATURED_CAROUSEL', + }) + ); + await createAppStoreListing( + makeListingInput({ + display_name: 'Curated App', + app_listing_status: 'LISTED', + promotion_level: 'CURATED_LIST', + }) + ); + await createAppStoreListing( + makeListingInput({ + display_name: 'Standard App', + app_listing_status: 'LISTED', + promotion_level: 'STANDARD', + }) + ); + + const featured = await getListedApps({ + limit: 10, + promotionLevel: 'FEATURED_CAROUSEL', + }); + expect(featured.length).toBe(1); + expect(featured[0]?.display_name).toBe('Featured App'); + }); + + it('paginates listed apps', async () => { + await createAppStoreListing( + makeListingInput({ display_name: 'App 1', app_listing_status: 'LISTED' }) + ); + await createAppStoreListing( + makeListingInput({ display_name: 'App 2', app_listing_status: 'LISTED' }) + ); + await createAppStoreListing( + makeListingInput({ display_name: 'App 3', app_listing_status: 'LISTED' }) + ); + + const page1 = await getListedApps({ limit: 2 }); + expect(page1.length).toBe(2); + + const page2 = await getListedApps({ limit: 2, cursor: page1[1]?.listing_id }); + expect(page2.length).toBe(1); + }); + + it('combines multiple filters', async () => { + await createAppStoreListing( + makeListingInput({ + display_name: 'Learning Featured', + app_listing_status: 'LISTED', + category: 'Learning', + promotion_level: 'FEATURED_CAROUSEL', + }) + ); + await createAppStoreListing( + makeListingInput({ + display_name: 'Games Featured', + app_listing_status: 'LISTED', + category: 'Games', + promotion_level: 'FEATURED_CAROUSEL', + }) + ); + await createAppStoreListing( + makeListingInput({ + display_name: 'Learning Standard', + app_listing_status: 'LISTED', + category: 'Learning', + promotion_level: 'STANDARD', + }) + ); + + const filtered = await getListedApps({ + limit: 10, + category: 'Learning', + promotionLevel: 'FEATURED_CAROUSEL', + }); + expect(filtered.length).toBe(1); + expect(filtered[0]?.display_name).toBe('Learning Featured'); + }); + }); + + describe('Launch Types and Config', () => { + it('stores different launch types', async () => { + const types = [ + 'EMBEDDED_IFRAME', + 'SECOND_SCREEN', + 'DIRECT_LINK', + 'CONSENT_REDIRECT', + 'SERVER_HEADLESS', + ] as const; + + for (const launchType of types) { + const listing = await createAppStoreListing( + makeListingInput({ + display_name: `${launchType} App`, + launch_type: launchType, + launch_config_json: JSON.stringify({ type: launchType }), + }) + ); + + const fetched = await readAppStoreListingById(listing.listing_id); + expect(fetched?.launch_type).toBe(launchType); + expect(fetched?.launch_config_json).toContain(launchType); + } + }); + }); + + describe('Edge Cases', () => { + it('handles missing optional fields gracefully', async () => { + const listing = await createAppStoreListing( + makeListingInput({ + category: undefined, + promo_video_url: undefined, + promotion_level: undefined, + }) + ); + + expect(listing.category).toBeUndefined(); + expect(listing.promo_video_url).toBeUndefined(); + expect(listing.promotion_level).toBeUndefined(); + }); + + it('multiple profiles can install same app', async () => { + const listing = await createAppStoreListing(makeListingInput()); + + await installAppForProfile('usera', listing.listing_id); + await installAppForProfile('userb', listing.listing_id); + await installAppForProfile('userc', listing.listing_id); + + expect(await checkIfProfileInstalledApp('usera', listing.listing_id)).toBe(true); + expect(await checkIfProfileInstalledApp('userb', listing.listing_id)).toBe(true); + expect(await checkIfProfileInstalledApp('userc', listing.listing_id)).toBe(true); + + const count = await countProfilesInstalledApp(listing.listing_id); + expect(count).toBe(3); + }); + + it('profile can install multiple apps', async () => { + const listing1 = await createAppStoreListing( + makeListingInput({ display_name: 'App 1' }) + ); + const listing2 = await createAppStoreListing( + makeListingInput({ display_name: 'App 2' }) + ); + const listing3 = await createAppStoreListing( + makeListingInput({ display_name: 'App 3' }) + ); + + await installAppForProfile('usera', listing1.listing_id); + await installAppForProfile('usera', listing2.listing_id); + await installAppForProfile('usera', listing3.listing_id); + + const count = await countInstalledAppsForProfile('usera'); + expect(count).toBe(3); + }); + + it('integration can publish multiple listings', async () => { + const integration = await seedIntegration('Multi App Publisher', 'usera'); + + const listing1 = await createAppStoreListing( + makeListingInput({ display_name: 'App 1' }) + ); + const listing2 = await createAppStoreListing( + makeListingInput({ display_name: 'App 2' }) + ); + + await associateListingWithIntegration(listing1.listing_id, integration.id); + await associateListingWithIntegration(listing2.listing_id, integration.id); + + const count = await countListingsForIntegration(integration.id); + expect(count).toBe(2); + }); + }); + }); +}); From 6434a537590b05a586eecce06618a775aa7d2de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacks=C3=B3n=20Smith?= Date: Tue, 25 Nov 2025 16:12:43 -0500 Subject: [PATCH 003/165] Add app store admin privelege' --- .../src/accesslayer/app-store-listing/read.ts | 14 +- .../brain-service/src/app.ts | 3 + .../brain-service/src/constants/app-store.ts | 22 + .../brain-service/src/routes/app-store.ts | 658 +++++++++++++++++ .../test/app-store-listing.spec.ts | 665 +++++++++++++++++- .../brain-service/vite.config.ts | 1 + 6 files changed, 1361 insertions(+), 2 deletions(-) create mode 100644 services/learn-card-network/brain-service/src/constants/app-store.ts create mode 100644 services/learn-card-network/brain-service/src/routes/app-store.ts diff --git a/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/read.ts b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/read.ts index ded46f2be2..e9ed26b534 100644 --- a/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/read.ts +++ b/services/learn-card-network/brain-service/src/accesslayer/app-store-listing/read.ts @@ -61,16 +61,28 @@ export const getListedApps = async ( cursor, category, promotionLevel, + status, + includeAllStatuses = false, }: { limit: number; cursor?: string; category?: string; promotionLevel?: string; + status?: string; // When provided, filter by this specific status + includeAllStatuses?: boolean; // When true, returns all statuses (for admin) } ): Promise => { - const whereClauses: string[] = ["listing.app_listing_status = 'LISTED'"]; + const whereClauses: string[] = []; const params: Record = { limit: int(limit) }; + // Status filtering: specific status > all statuses > default to LISTED only + if (status) { + whereClauses.push('listing.app_listing_status = $status'); + params.status = status; + } else if (!includeAllStatuses) { + whereClauses.push("listing.app_listing_status = 'LISTED'"); + } + if (cursor) { whereClauses.push('listing.listing_id < $cursor'); params.cursor = cursor; diff --git a/services/learn-card-network/brain-service/src/app.ts b/services/learn-card-network/brain-service/src/app.ts index a0e871d58d..f966591255 100644 --- a/services/learn-card-network/brain-service/src/app.ts +++ b/services/learn-card-network/brain-service/src/app.ts @@ -16,6 +16,7 @@ import { inboxRouter, InboxRouter } from '@routes/inbox'; import { skillFrameworksRouter, SkillFrameworksRouter } from '@routes/skill-frameworks'; import { skillsRouter, SkillsRouter } from '@routes/skills'; import { integrationsRouter, IntegrationsRouter } from '@routes/integrations'; +import { appStoreRouter, AppStoreRouter } from '@routes/app-store'; /** For end-to-end testing, only available in test environment */ import { testRouter, TestRouter } from '@routes/test'; @@ -40,6 +41,7 @@ export const appRouter = t.router<{ skillFrameworks: SkillFrameworksRouter; skills: SkillsRouter; integrations: IntegrationsRouter; + appStore: AppStoreRouter; test?: TestRouter; }>({ boost: boostsRouter, @@ -59,6 +61,7 @@ export const appRouter = t.router<{ skillFrameworks: skillFrameworksRouter, skills: skillsRouter, integrations: integrationsRouter, + appStore: appStoreRouter, test: !!process.env.IS_E2E_TEST ? testRouter : undefined, }); diff --git a/services/learn-card-network/brain-service/src/constants/app-store.ts b/services/learn-card-network/brain-service/src/constants/app-store.ts new file mode 100644 index 0000000000..bbbc0a9d76 --- /dev/null +++ b/services/learn-card-network/brain-service/src/constants/app-store.ts @@ -0,0 +1,22 @@ +/** + * App Store Admin Configuration + * + * Profile IDs listed here have administrative privileges for the App Store, + * including the ability to approve/reject listings and set promotion levels. + * + * Set via APP_STORE_ADMIN_PROFILE_IDS environment variable as a comma-separated list. + * Example: APP_STORE_ADMIN_PROFILE_IDS=profile-id-1,profile-id-2 + */ +export const APP_STORE_ADMIN_PROFILE_IDS: string[] = ( + process.env.APP_STORE_ADMIN_PROFILE_IDS ?? '' +) + .split(',') + .map(id => id.trim()) + .filter(id => id.length > 0); + +/** + * Check if a profile has App Store admin privileges + */ +export const isAppStoreAdmin = (profileId: string): boolean => { + return APP_STORE_ADMIN_PROFILE_IDS.includes(profileId); +}; diff --git a/services/learn-card-network/brain-service/src/routes/app-store.ts b/services/learn-card-network/brain-service/src/routes/app-store.ts new file mode 100644 index 0000000000..f0fbe5bca6 --- /dev/null +++ b/services/learn-card-network/brain-service/src/routes/app-store.ts @@ -0,0 +1,658 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { t, openRoute, profileRoute } from '@routes'; +import { isAppStoreAdmin } from 'src/constants/app-store'; + +import { createAppStoreListing } from '@accesslayer/app-store-listing/create'; +import { + readAppStoreListingById, + getListingsForIntegration, + countListingsForIntegration, + getListedApps, + getInstalledAppsForProfile, + countInstalledAppsForProfile, + checkIfProfileInstalledApp, +} from '@accesslayer/app-store-listing/read'; +import { updateAppStoreListing } from '@accesslayer/app-store-listing/update'; +import { deleteAppStoreListing } from '@accesslayer/app-store-listing/delete'; +import { + associateListingWithIntegration, + installAppForProfile, +} from '@accesslayer/app-store-listing/relationships/create'; +import { uninstallAppForProfile } from '@accesslayer/app-store-listing/relationships/delete'; +import { + getIntegrationForListing, + countProfilesInstalledApp, +} from '@accesslayer/app-store-listing/relationships/read'; +import { readIntegrationById } from '@accesslayer/integration/read'; +import { isIntegrationAssociatedWithProfile } from '@accesslayer/integration/relationships/read'; +import { + AppListingStatus, + LaunchType, + PromotionLevel, + AppStoreListingValidator, +} from 'types/app-store-listing'; + +// Zod validators for API +const AppStoreListingInputValidator = z.object({ + display_name: z.string().min(1).max(100), + tagline: z.string().min(1).max(200), + full_description: z.string().min(1).max(5000), + icon_url: z.string().url(), + app_listing_status: AppListingStatus, + launch_type: LaunchType, + launch_config_json: z.string(), + category: z.string().optional(), + promo_video_url: z.string().url().optional(), + promotion_level: PromotionLevel.optional(), + ios_app_store_id: z.string().optional(), + android_app_store_id: z.string().optional(), + privacy_policy_url: z.string().url().optional(), + terms_url: z.string().url().optional(), +}); + +// Regular update validator - excludes admin-only fields +const AppStoreListingUpdateInputValidator = AppStoreListingInputValidator.partial().omit({ + app_listing_status: true, + promotion_level: true, +}); + +// Create validator - new listings start as DRAFT, no promotion level +const AppStoreListingCreateInputValidator = AppStoreListingInputValidator.omit({ + app_listing_status: true, + promotion_level: true, +}); + +const PaginatedAppStoreListingsValidator = z.object({ + hasMore: z.boolean(), + cursor: z.string().optional(), + records: z.array(AppStoreListingValidator), +}); + +const InstalledAppValidator = AppStoreListingValidator.extend({ + installed_at: z.string(), +}); + +const PaginatedInstalledAppsValidator = z.object({ + hasMore: z.boolean(), + cursor: z.string().optional(), + records: z.array(InstalledAppValidator), +}); + +// Helper to verify integration ownership +const verifyIntegrationOwnership = async (integrationId: string, profileId: string) => { + const associated = await isIntegrationAssociatedWithProfile(integrationId, profileId); + + if (!associated) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'This Integration is not associated with you!', + }); + } + + const integration = await readIntegrationById(integrationId); + + if (!integration) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Integration not found' }); + } + + return integration; +}; + +// Helper to verify listing ownership via integration +const verifyListingOwnership = async (listingId: string, profileId: string) => { + const listing = await readAppStoreListingById(listingId); + + if (!listing) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'App Store Listing not found' }); + } + + const integration = await getIntegrationForListing(listingId); + + if (!integration) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Listing is not associated with any Integration', + }); + } + + await verifyIntegrationOwnership(integration.id, profileId); + + return { listing, integration }; +}; + +// Helper to verify app store admin privileges +const verifyAppStoreAdmin = (profileId: string) => { + if (!isAppStoreAdmin(profileId)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Only App Store administrators can perform this action', + }); + } +}; + +// Helper to get listing without ownership check (for admin routes) +const getListingOrThrow = async (listingId: string) => { + const listing = await readAppStoreListingById(listingId); + + if (!listing) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'App Store Listing not found' }); + } + + return listing; +}; + +export const appStoreRouter = t.router({ + // ==================== Integration Owner Routes ==================== + + createListing: profileRoute + .meta({ + openapi: { + protect: true, + method: 'POST', + path: '/app-store/listing/create', + tags: ['App Store'], + summary: 'Create App Store Listing', + description: 'Create a new App Store Listing for your Integration', + }, + requiredScope: 'app-store:write', + }) + .input( + z.object({ + integrationId: z.string(), + listing: AppStoreListingCreateInputValidator, + }) + ) + .output(z.string()) + .mutation(async ({ input, ctx }) => { + await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId); + + // New listings always start as DRAFT with STANDARD promotion + const listing = await createAppStoreListing({ + ...input.listing, + app_listing_status: 'DRAFT', + promotion_level: 'STANDARD', + }); + + await associateListingWithIntegration(listing.listing_id, input.integrationId); + + return listing.listing_id; + }), + + getListing: profileRoute + .meta({ + openapi: { + protect: true, + method: 'GET', + path: '/app-store/listing/{listingId}', + tags: ['App Store'], + summary: 'Get App Store Listing (Owner)', + description: 'Get an App Store Listing by id (for integration owners)', + }, + requiredScope: 'app-store:read', + }) + .input(z.object({ listingId: z.string() })) + .output(AppStoreListingValidator.optional()) + .query(async ({ input, ctx }) => { + const { listing } = await verifyListingOwnership( + input.listingId, + ctx.user.profile.profileId + ); + + return listing; + }), + + getListingsForIntegration: profileRoute + .meta({ + openapi: { + protect: true, + method: 'POST', + path: '/app-store/integration/{integrationId}/listings', + tags: ['App Store'], + summary: 'Get Listings for Integration', + description: 'Get all App Store Listings for your Integration', + }, + requiredScope: 'app-store:read', + }) + .input( + z.object({ + integrationId: z.string(), + limit: z.number().optional(), + cursor: z.string().optional(), + }) + ) + .output(PaginatedAppStoreListingsValidator) + .query(async ({ input, ctx }) => { + await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId); + + const limit = input.limit ?? 25; + const results = await getListingsForIntegration(input.integrationId, { + limit: limit + 1, + cursor: input.cursor, + }); + + const hasMore = results.length > limit; + const records = hasMore ? results.slice(0, limit) : results; + const cursor = hasMore ? records[records.length - 1]?.listing_id : undefined; + + return { hasMore, cursor, records }; + }), + + countListingsForIntegration: profileRoute + .meta({ + openapi: { + protect: true, + method: 'GET', + path: '/app-store/integration/{integrationId}/listings/count', + tags: ['App Store'], + summary: 'Count Listings for Integration', + description: 'Count App Store Listings for your Integration', + }, + requiredScope: 'app-store:read', + }) + .input(z.object({ integrationId: z.string() })) + .output(z.number()) + .query(async ({ input, ctx }) => { + await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId); + + return countListingsForIntegration(input.integrationId); + }), + + updateListing: profileRoute + .meta({ + openapi: { + protect: true, + method: 'POST', + path: '/app-store/listing/{listingId}/update', + tags: ['App Store'], + summary: 'Update App Store Listing', + description: 'Update an App Store Listing', + }, + requiredScope: 'app-store:write', + }) + .input( + z.object({ + listingId: z.string(), + updates: AppStoreListingUpdateInputValidator, + }) + ) + .output(z.boolean()) + .mutation(async ({ input, ctx }) => { + const { listing } = await verifyListingOwnership( + input.listingId, + ctx.user.profile.profileId + ); + + return updateAppStoreListing(listing, input.updates); + }), + + deleteListing: profileRoute + .meta({ + openapi: { + protect: true, + method: 'DELETE', + path: '/app-store/listing/{listingId}', + tags: ['App Store'], + summary: 'Delete App Store Listing', + description: 'Delete an App Store Listing', + }, + requiredScope: 'app-store:delete', + }) + .input(z.object({ listingId: z.string() })) + .output(z.boolean()) + .mutation(async ({ input, ctx }) => { + await verifyListingOwnership(input.listingId, ctx.user.profile.profileId); + + await deleteAppStoreListing(input.listingId); + + return true; + }), + + // ==================== Public Browse Routes ==================== + + browseListedApps: openRoute + .meta({ + openapi: { + protect: false, + method: 'POST', + path: '/app-store/browse', + tags: ['App Store'], + summary: 'Browse App Store', + description: 'Browse all publicly listed apps in the App Store', + }, + }) + .input( + z + .object({ + limit: z.number().optional(), + cursor: z.string().optional(), + category: z.string().optional(), + promotionLevel: PromotionLevel.optional(), + }) + .optional() + ) + .output(PaginatedAppStoreListingsValidator) + .query(async ({ input }) => { + const limit = input?.limit ?? 25; + const results = await getListedApps({ + limit: limit + 1, + cursor: input?.cursor, + category: input?.category, + promotionLevel: input?.promotionLevel, + }); + + const hasMore = results.length > limit; + const records = hasMore ? results.slice(0, limit) : results; + const cursor = hasMore ? records[records.length - 1]?.listing_id : undefined; + + return { hasMore, cursor, records }; + }), + + getPublicListing: openRoute + .meta({ + openapi: { + protect: false, + method: 'GET', + path: '/app-store/public/listing/{listingId}', + tags: ['App Store'], + summary: 'Get Public App Listing', + description: 'Get a publicly listed app by id', + }, + }) + .input(z.object({ listingId: z.string() })) + .output(AppStoreListingValidator.optional()) + .query(async ({ input }) => { + const listing = await readAppStoreListingById(input.listingId); + + if (!listing || listing.app_listing_status !== 'LISTED') { + return undefined; + } + + return listing; + }), + + getListingInstallCount: openRoute + .meta({ + openapi: { + protect: false, + method: 'GET', + path: '/app-store/listing/{listingId}/install-count', + tags: ['App Store'], + summary: 'Get App Install Count', + description: 'Get the number of users who have installed an app', + }, + }) + .input(z.object({ listingId: z.string() })) + .output(z.number()) + .query(async ({ input }) => { + const listing = await readAppStoreListingById(input.listingId); + + if (!listing || listing.app_listing_status !== 'LISTED') { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Listing not found' }); + } + + return countProfilesInstalledApp(input.listingId); + }), + + // ==================== User Install/Uninstall Routes ==================== + + installApp: profileRoute + .meta({ + openapi: { + protect: true, + method: 'POST', + path: '/app-store/listing/{listingId}/install', + tags: ['App Store'], + summary: 'Install App', + description: 'Install an app from the App Store', + }, + requiredScope: 'app-store:write', + }) + .input(z.object({ listingId: z.string() })) + .output(z.boolean()) + .mutation(async ({ input, ctx }) => { + const listing = await readAppStoreListingById(input.listingId); + + if (!listing || listing.app_listing_status !== 'LISTED') { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Listing not found or not available' }); + } + + const alreadyInstalled = await checkIfProfileInstalledApp( + ctx.user.profile.profileId, + input.listingId + ); + + if (alreadyInstalled) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'You have already installed this app', + }); + } + + await installAppForProfile(ctx.user.profile.profileId, input.listingId); + + return true; + }), + + uninstallApp: profileRoute + .meta({ + openapi: { + protect: true, + method: 'POST', + path: '/app-store/listing/{listingId}/uninstall', + tags: ['App Store'], + summary: 'Uninstall App', + description: 'Uninstall an app from your profile', + }, + requiredScope: 'app-store:write', + }) + .input(z.object({ listingId: z.string() })) + .output(z.boolean()) + .mutation(async ({ input, ctx }) => { + const isInstalled = await checkIfProfileInstalledApp( + ctx.user.profile.profileId, + input.listingId + ); + + if (!isInstalled) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'You have not installed this app', + }); + } + + await uninstallAppForProfile(ctx.user.profile.profileId, input.listingId); + + return true; + }), + + getInstalledApps: profileRoute + .meta({ + openapi: { + protect: true, + method: 'POST', + path: '/app-store/installed', + tags: ['App Store'], + summary: 'Get Installed Apps', + description: 'Get all apps you have installed', + }, + requiredScope: 'app-store:read', + }) + .input( + z + .object({ + limit: z.number().optional(), + cursor: z.string().optional(), + }) + .optional() + ) + .output(PaginatedInstalledAppsValidator) + .query(async ({ input, ctx }) => { + const limit = input?.limit ?? 25; + const results = await getInstalledAppsForProfile(ctx.user.profile.profileId, { + limit: limit + 1, + cursor: input?.cursor, + }); + + const hasMore = results.length > limit; + const records = hasMore ? results.slice(0, limit) : results; + const cursor = hasMore ? records[records.length - 1]?.installed_at : undefined; + + return { hasMore, cursor, records }; + }), + + countInstalledApps: profileRoute + .meta({ + openapi: { + protect: true, + method: 'GET', + path: '/app-store/installed/count', + tags: ['App Store'], + summary: 'Count Installed Apps', + description: 'Count all apps you have installed', + }, + requiredScope: 'app-store:read', + }) + .input(z.void()) + .output(z.number()) + .query(async ({ ctx }) => { + return countInstalledAppsForProfile(ctx.user.profile.profileId); + }), + + isAppInstalled: profileRoute + .meta({ + openapi: { + protect: true, + method: 'GET', + path: '/app-store/listing/{listingId}/is-installed', + tags: ['App Store'], + summary: 'Check if App is Installed', + description: 'Check if you have installed a specific app', + }, + requiredScope: 'app-store:read', + }) + .input(z.object({ listingId: z.string() })) + .output(z.boolean()) + .query(async ({ input, ctx }) => { + return checkIfProfileInstalledApp(ctx.user.profile.profileId, input.listingId); + }), + + // ==================== Admin Routes ==================== + + adminUpdateListingStatus: profileRoute + .meta({ + openapi: { + protect: true, + method: 'POST', + path: '/app-store/admin/listing/{listingId}/status', + tags: ['App Store Admin'], + summary: 'Update Listing Status (Admin)', + description: 'Update the status of an App Store Listing (admin only)', + }, + requiredScope: 'app-store:admin', + }) + .input( + z.object({ + listingId: z.string(), + status: AppListingStatus, + }) + ) + .output(z.boolean()) + .mutation(async ({ input, ctx }) => { + verifyAppStoreAdmin(ctx.user.profile.profileId); + + const listing = await getListingOrThrow(input.listingId); + + return updateAppStoreListing(listing, { app_listing_status: input.status }); + }), + + adminUpdatePromotionLevel: profileRoute + .meta({ + openapi: { + protect: true, + method: 'POST', + path: '/app-store/admin/listing/{listingId}/promotion', + tags: ['App Store Admin'], + summary: 'Update Promotion Level (Admin)', + description: 'Update the promotion level of an App Store Listing (admin only)', + }, + requiredScope: 'app-store:admin', + }) + .input( + z.object({ + listingId: z.string(), + promotionLevel: PromotionLevel, + }) + ) + .output(z.boolean()) + .mutation(async ({ input, ctx }) => { + verifyAppStoreAdmin(ctx.user.profile.profileId); + + const listing = await getListingOrThrow(input.listingId); + + return updateAppStoreListing(listing, { promotion_level: input.promotionLevel }); + }), + + adminGetAllListings: profileRoute + .meta({ + openapi: { + protect: true, + method: 'POST', + path: '/app-store/admin/listings', + tags: ['App Store Admin'], + summary: 'Get All Listings (Admin)', + description: 'Get all App Store Listings regardless of status (admin only)', + }, + requiredScope: 'app-store:admin', + }) + .input( + z + .object({ + limit: z.number().optional(), + cursor: z.string().optional(), + status: AppListingStatus.optional(), + }) + .optional() + ) + .output(PaginatedAppStoreListingsValidator) + .query(async ({ input, ctx }) => { + verifyAppStoreAdmin(ctx.user.profile.profileId); + + const limit = input?.limit ?? 25; + + // Get listings with optional status filter, or all statuses if not specified + const results = await getListedApps({ + limit: limit + 1, + cursor: input?.cursor, + status: input?.status, + includeAllStatuses: !input?.status, // Include all if no specific status filter + }); + + const hasMore = results.length > limit; + const records = hasMore ? results.slice(0, limit) : results; + const cursor = hasMore ? records[records.length - 1]?.listing_id : undefined; + + return { hasMore, cursor, records }; + }), + + isAdmin: profileRoute + .meta({ + openapi: { + protect: true, + method: 'GET', + path: '/app-store/admin/check', + tags: ['App Store Admin'], + summary: 'Check Admin Status', + description: 'Check if the current user is an App Store administrator', + }, + requiredScope: 'app-store:read', + }) + .input(z.void()) + .output(z.boolean()) + .query(async ({ ctx }) => { + return isAppStoreAdmin(ctx.user.profile.profileId); + }), +}); + +export type AppStoreRouter = typeof appStoreRouter; diff --git a/services/learn-card-network/brain-service/test/app-store-listing.spec.ts b/services/learn-card-network/brain-service/test/app-store-listing.spec.ts index 49fc5fe71a..9afe776c7a 100644 --- a/services/learn-card-network/brain-service/test/app-store-listing.spec.ts +++ b/services/learn-card-network/brain-service/test/app-store-listing.spec.ts @@ -1,6 +1,6 @@ import { describe, it, beforeAll, beforeEach, afterAll, expect } from 'vitest'; -import { getUser } from './helpers/getClient'; +import { getClient, getUser } from './helpers/getClient'; import { AppStoreListing, Integration, Profile } from '@models'; @@ -36,6 +36,7 @@ let userA: Awaited>; let userB: Awaited>; let userC: Awaited>; +// For access layer tests - can set all fields including protected ones const makeListingInput = (overrides?: Record) => ({ display_name: 'Test App', tagline: 'A test application', @@ -49,6 +50,12 @@ const makeListingInput = (overrides?: Record) => ({ ...overrides, }); +// For router tests - excludes protected fields (app_listing_status, promotion_level) +const makeRouterListingInput = (overrides?: Record) => { + const { app_listing_status, promotion_level, ...rest } = makeListingInput(overrides); + return rest; +}; + const seedProfile = async (user: Awaited>, profileId: string) => { await user.clients.fullAuth.profile.createProfile({ profileId }); }; @@ -547,4 +554,660 @@ describe('AppStoreListing', () => { }); }); }); + + describe('Router', () => { + const noAuthClient = getClient(); + + let adminUser: Awaited>; + + beforeAll(async () => { + userA = await getUser('a'.repeat(64)); + userB = await getUser('b'.repeat(64)); + userC = await getUser('c'.repeat(64), 'app-store:write'); + adminUser = await getUser('d'.repeat(64)); + }); + + beforeEach(async () => { + await AppStoreListing.delete({ detach: true, where: {} }); + await Integration.delete({ detach: true, where: {} }); + await Profile.delete({ detach: true, where: {} }); + + await seedProfile(userA, 'usera'); + await seedProfile(userB, 'userb'); + await seedProfile(adminUser, 'app-store-admin'); + // userC intentionally has no profile for NOT_FOUND checks + }); + + afterAll(async () => { + await AppStoreListing.delete({ detach: true, where: {} }); + await Integration.delete({ detach: true, where: {} }); + await Profile.delete({ detach: true, where: {} }); + }); + + const seedIntegrationViaRouter = async ( + user: Awaited>, + name = 'Test Integration' + ) => { + const id = await user.clients.fullAuth.integrations.addIntegration({ + name, + description: 'Test integration', + whitelistedDomains: ['example.com'], + }); + return id; + }; + + const seedListingViaRouter = async ( + user: Awaited>, + integrationId: string, + overrides?: Record + ) => { + // Router creates listings as DRAFT - protected fields are stripped + const listingId = await user.clients.fullAuth.appStore.createListing({ + integrationId, + listing: makeRouterListingInput(overrides), + }); + return listingId; + }; + + // Helper to set listing status via access layer (simulating admin action for test setup) + const setListingStatus = async ( + listingId: string, + status: 'DRAFT' | 'LISTED' | 'ARCHIVED' + ) => { + const listing = await readAppStoreListingById(listingId); + if (listing) { + await updateAppStoreListing(listing, { app_listing_status: status }); + } + }; + + describe('createListing', () => { + it('requires app-store:write scope and an existing profile', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + await expect( + noAuthClient.appStore.createListing({ + integrationId, + listing: makeRouterListingInput(), + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + await expect( + userA.clients.partialAuth.appStore.createListing({ + integrationId, + listing: makeRouterListingInput(), + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + await expect( + userC.clients.fullAuth.appStore.createListing({ + integrationId, + listing: makeRouterListingInput(), + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('requires ownership of the integration', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + await expect( + userB.clients.fullAuth.appStore.createListing({ + integrationId, + listing: makeRouterListingInput(), + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('creates a listing as DRAFT with STANDARD promotion', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + + expect(typeof listingId).toBe('string'); + + const listing = await userA.clients.fullAuth.appStore.getListing({ listingId }); + expect(listing?.display_name).toBe('Test App'); + // Verify protected defaults are set correctly + expect(listing?.app_listing_status).toBe('DRAFT'); + expect(listing?.promotion_level).toBe('STANDARD'); + }); + }); + + describe('getListing', () => { + it('requires app-store:read and enforces ownership', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + + await expect( + userB.clients.fullAuth.appStore.getListing({ listingId }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + const listing = await userA.clients.fullAuth.appStore.getListing({ listingId }); + expect(listing?.listing_id).toBe(listingId); + }); + }); + + describe('getListingsForIntegration', () => { + it('paginates listings', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + await seedListingViaRouter(userA, integrationId, { display_name: 'App 1' }); + await seedListingViaRouter(userA, integrationId, { display_name: 'App 2' }); + await seedListingViaRouter(userA, integrationId, { display_name: 'App 3' }); + + const page1 = await userA.clients.fullAuth.appStore.getListingsForIntegration({ + integrationId, + limit: 2, + }); + expect(page1.records.length).toBe(2); + expect(page1.hasMore).toBe(true); + + const page2 = await userA.clients.fullAuth.appStore.getListingsForIntegration({ + integrationId, + limit: 2, + cursor: page1.cursor!, + }); + expect(page2.records.length).toBe(1); + expect(page2.hasMore).toBe(false); + }); + }); + + describe('countListingsForIntegration', () => { + it('counts listings for integration', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + await seedListingViaRouter(userA, integrationId, { display_name: 'App 1' }); + await seedListingViaRouter(userA, integrationId, { display_name: 'App 2' }); + + const count = await userA.clients.fullAuth.appStore.countListingsForIntegration({ + integrationId, + }); + expect(count).toBe(2); + }); + }); + + describe('updateListing', () => { + it('requires app-store:write and ownership', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + + await expect( + userB.clients.fullAuth.appStore.updateListing({ + listingId, + updates: { display_name: 'Nope' }, + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + const ok = await userA.clients.fullAuth.appStore.updateListing({ + listingId, + updates: { display_name: 'Updated App', tagline: 'New tagline' }, + }); + expect(ok).toBe(true); + + const after = await userA.clients.fullAuth.appStore.getListing({ listingId }); + expect(after?.display_name).toBe('Updated App'); + expect(after?.tagline).toBe('New tagline'); + }); + }); + + describe('deleteListing', () => { + it('requires app-store:delete and ownership', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + + await expect( + userB.clients.fullAuth.appStore.deleteListing({ listingId }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + const ok = await userA.clients.fullAuth.appStore.deleteListing({ listingId }); + expect(ok).toBe(true); + + const after = await readAppStoreListingById(listingId); + expect(after).toBeNull(); + }); + }); + + describe('browseListedApps (public)', () => { + it('returns only LISTED apps without auth', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + // Create draft app (stays as DRAFT) + await seedListingViaRouter(userA, integrationId, { display_name: 'Draft App' }); + + // Create and set to LISTED + const listedId = await seedListingViaRouter(userA, integrationId, { + display_name: 'Listed App', + }); + await setListingStatus(listedId, 'LISTED'); + + const result = await noAuthClient.appStore.browseListedApps(); + + expect(result.records.length).toBe(1); + expect(result.records[0]?.display_name).toBe('Listed App'); + }); + + it('filters by category', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + const learning = await seedListingViaRouter(userA, integrationId, { + display_name: 'Learning App', + category: 'Learning', + }); + await setListingStatus(learning, 'LISTED'); + + const games = await seedListingViaRouter(userA, integrationId, { + display_name: 'Games App', + category: 'Games', + }); + await setListingStatus(games, 'LISTED'); + + const result = await noAuthClient.appStore.browseListedApps({ + category: 'Learning', + }); + + expect(result.records.length).toBe(1); + expect(result.records[0]?.display_name).toBe('Learning App'); + }); + + it('filters by promotion level', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + // Use access layer to set promotion level directly (admin-only field) + const featured = await seedListingViaRouter(userA, integrationId, { + display_name: 'Featured App', + }); + const featuredListing = await readAppStoreListingById(featured); + await updateAppStoreListing(featuredListing!, { + app_listing_status: 'LISTED', + promotion_level: 'FEATURED_CAROUSEL', + }); + + const standard = await seedListingViaRouter(userA, integrationId, { + display_name: 'Standard App', + }); + await setListingStatus(standard, 'LISTED'); + + const result = await noAuthClient.appStore.browseListedApps({ + promotionLevel: 'FEATURED_CAROUSEL', + }); + + expect(result.records.length).toBe(1); + expect(result.records[0]?.display_name).toBe('Featured App'); + }); + }); + + describe('getPublicListing', () => { + it('returns listed apps without auth', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId, { + display_name: 'Public App', + }); + await setListingStatus(listingId, 'LISTED'); + + const listing = await noAuthClient.appStore.getPublicListing({ listingId }); + expect(listing?.display_name).toBe('Public App'); + }); + + it('returns undefined for non-LISTED apps', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId, { + display_name: 'Draft App', + }); + // Listing stays as DRAFT by default + + const listing = await noAuthClient.appStore.getPublicListing({ listingId }); + expect(listing).toBeUndefined(); + }); + }); + + describe('getListingInstallCount', () => { + it('returns install count for listed apps', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + await setListingStatus(listingId, 'LISTED'); + + // Install the app + await userA.clients.fullAuth.appStore.installApp({ listingId }); + await userB.clients.fullAuth.appStore.installApp({ listingId }); + + const count = await noAuthClient.appStore.getListingInstallCount({ listingId }); + expect(count).toBe(2); + }); + }); + + describe('installApp', () => { + it('requires app-store:write scope', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + await setListingStatus(listingId, 'LISTED'); + + await expect( + noAuthClient.appStore.installApp({ listingId }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + await expect( + userA.clients.partialAuth.appStore.installApp({ listingId }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('installs a listed app', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + await setListingStatus(listingId, 'LISTED'); + + const ok = await userA.clients.fullAuth.appStore.installApp({ listingId }); + expect(ok).toBe(true); + + const isInstalled = await userA.clients.fullAuth.appStore.isAppInstalled({ + listingId, + }); + expect(isInstalled).toBe(true); + }); + + it('prevents installing non-LISTED apps', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + // Listing stays as DRAFT by default + + await expect( + userA.clients.fullAuth.appStore.installApp({ listingId }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('prevents duplicate installations', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + await setListingStatus(listingId, 'LISTED'); + + await userA.clients.fullAuth.appStore.installApp({ listingId }); + + await expect( + userA.clients.fullAuth.appStore.installApp({ listingId }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + }); + }); + + describe('uninstallApp', () => { + it('uninstalls an installed app', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + await setListingStatus(listingId, 'LISTED'); + + await userA.clients.fullAuth.appStore.installApp({ listingId }); + const ok = await userA.clients.fullAuth.appStore.uninstallApp({ listingId }); + expect(ok).toBe(true); + + const isInstalled = await userA.clients.fullAuth.appStore.isAppInstalled({ + listingId, + }); + expect(isInstalled).toBe(false); + }); + + it('fails if app is not installed', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + await setListingStatus(listingId, 'LISTED'); + + await expect( + userA.clients.fullAuth.appStore.uninstallApp({ listingId }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + }); + + describe('getInstalledApps', () => { + it('returns installed apps with pagination', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + const listing1 = await seedListingViaRouter(userA, integrationId, { + display_name: 'App 1', + }); + await setListingStatus(listing1, 'LISTED'); + + const listing2 = await seedListingViaRouter(userA, integrationId, { + display_name: 'App 2', + }); + await setListingStatus(listing2, 'LISTED'); + + const listing3 = await seedListingViaRouter(userA, integrationId, { + display_name: 'App 3', + }); + await setListingStatus(listing3, 'LISTED'); + + await userA.clients.fullAuth.appStore.installApp({ listingId: listing1 }); + await userA.clients.fullAuth.appStore.installApp({ listingId: listing2 }); + await userA.clients.fullAuth.appStore.installApp({ listingId: listing3 }); + + const page1 = await userA.clients.fullAuth.appStore.getInstalledApps({ limit: 2 }); + expect(page1.records.length).toBe(2); + expect(page1.hasMore).toBe(true); + + const page2 = await userA.clients.fullAuth.appStore.getInstalledApps({ + limit: 2, + cursor: page1.cursor!, + }); + expect(page2.records.length).toBe(1); + expect(page2.hasMore).toBe(false); + }); + }); + + describe('countInstalledApps', () => { + it('counts installed apps', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + const listing1 = await seedListingViaRouter(userA, integrationId, { + display_name: 'App 1', + }); + await setListingStatus(listing1, 'LISTED'); + + const listing2 = await seedListingViaRouter(userA, integrationId, { + display_name: 'App 2', + }); + await setListingStatus(listing2, 'LISTED'); + + await userA.clients.fullAuth.appStore.installApp({ listingId: listing1 }); + await userA.clients.fullAuth.appStore.installApp({ listingId: listing2 }); + + const count = await userA.clients.fullAuth.appStore.countInstalledApps(); + expect(count).toBe(2); + }); + }); + + describe('isAppInstalled', () => { + it('returns correct installation status', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + await setListingStatus(listingId, 'LISTED'); + + const before = await userA.clients.fullAuth.appStore.isAppInstalled({ listingId }); + expect(before).toBe(false); + + await userA.clients.fullAuth.appStore.installApp({ listingId }); + + const after = await userA.clients.fullAuth.appStore.isAppInstalled({ listingId }); + expect(after).toBe(true); + }); + }); + + describe('Admin Routes', () => { + // For admin tests, we need to temporarily add the user to the admin list + // Since the admin list is config-based, we'll use the access layer directly + // to verify admin-only behavior works correctly + + it('adminUpdateListingStatus - rejects non-admin users', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + + // userA is not an admin + await expect( + userA.clients.fullAuth.appStore.adminUpdateListingStatus({ + listingId, + status: 'LISTED', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + // Listing should still be DRAFT + const listing = await readAppStoreListingById(listingId); + expect(listing?.app_listing_status).toBe('DRAFT'); + }); + + it('adminUpdatePromotionLevel - rejects non-admin users', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + + await expect( + userA.clients.fullAuth.appStore.adminUpdatePromotionLevel({ + listingId, + promotionLevel: 'FEATURED_CAROUSEL', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + // Promotion level should still be STANDARD + const listing = await readAppStoreListingById(listingId); + expect(listing?.promotion_level).toBe('STANDARD'); + }); + + it('adminGetAllListings - rejects non-admin users', async () => { + await expect( + userA.clients.fullAuth.appStore.adminGetAllListings() + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('isAdmin - returns false for non-admin users', async () => { + const isAdmin = await userA.clients.fullAuth.appStore.isAdmin(); + expect(isAdmin).toBe(false); + }); + + it('isAdmin - returns true for admin users', async () => { + const isAdmin = await adminUser.clients.fullAuth.appStore.isAdmin(); + expect(isAdmin).toBe(true); + }); + + it('adminUpdateListingStatus - admin can update listing status', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + + // Verify starts as DRAFT + const before = await readAppStoreListingById(listingId); + expect(before?.app_listing_status).toBe('DRAFT'); + + // Admin updates to LISTED + const result = await adminUser.clients.fullAuth.appStore.adminUpdateListingStatus({ + listingId, + status: 'LISTED', + }); + expect(result).toBe(true); + + const after = await readAppStoreListingById(listingId); + expect(after?.app_listing_status).toBe('LISTED'); + + // Admin can also archive + await adminUser.clients.fullAuth.appStore.adminUpdateListingStatus({ + listingId, + status: 'ARCHIVED', + }); + + const archived = await readAppStoreListingById(listingId); + expect(archived?.app_listing_status).toBe('ARCHIVED'); + }); + + it('adminUpdatePromotionLevel - admin can update promotion level', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + + // Verify starts as STANDARD + const before = await readAppStoreListingById(listingId); + expect(before?.promotion_level).toBe('STANDARD'); + + // Admin updates to FEATURED_CAROUSEL + const result = await adminUser.clients.fullAuth.appStore.adminUpdatePromotionLevel({ + listingId, + promotionLevel: 'FEATURED_CAROUSEL', + }); + expect(result).toBe(true); + + const after = await readAppStoreListingById(listingId); + expect(after?.promotion_level).toBe('FEATURED_CAROUSEL'); + + // Admin can set other levels too + await adminUser.clients.fullAuth.appStore.adminUpdatePromotionLevel({ + listingId, + promotionLevel: 'CURATED_LIST', + }); + + const curated = await readAppStoreListingById(listingId); + expect(curated?.promotion_level).toBe('CURATED_LIST'); + }); + + it('adminGetAllListings - admin can view all listings regardless of status', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + // Create listings with different statuses + const draft = await seedListingViaRouter(userA, integrationId, { + display_name: 'Draft App', + }); + + const listed = await seedListingViaRouter(userA, integrationId, { + display_name: 'Listed App', + }); + await setListingStatus(listed, 'LISTED'); + + const archived = await seedListingViaRouter(userA, integrationId, { + display_name: 'Archived App', + }); + await setListingStatus(archived, 'ARCHIVED'); + + // Admin can see all listings + const allListings = await adminUser.clients.fullAuth.appStore.adminGetAllListings(); + expect(allListings.records.length).toBe(3); + + const names = allListings.records.map(l => l.display_name); + expect(names).toContain('Draft App'); + expect(names).toContain('Listed App'); + expect(names).toContain('Archived App'); + }); + + it('adminGetAllListings - admin can filter by status', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + + await seedListingViaRouter(userA, integrationId, { display_name: 'Draft App' }); + + const listed = await seedListingViaRouter(userA, integrationId, { + display_name: 'Listed App', + }); + await setListingStatus(listed, 'LISTED'); + + // Admin filters by DRAFT only + const draftOnly = await adminUser.clients.fullAuth.appStore.adminGetAllListings({ + status: 'DRAFT', + }); + expect(draftOnly.records.length).toBe(1); + expect(draftOnly.records[0]?.display_name).toBe('Draft App'); + + // Admin filters by LISTED only + const listedOnly = await adminUser.clients.fullAuth.appStore.adminGetAllListings({ + status: 'LISTED', + }); + expect(listedOnly.records.length).toBe(1); + expect(listedOnly.records[0]?.display_name).toBe('Listed App'); + }); + + it('regular updateListing - cannot change protected fields', async () => { + const integrationId = await seedIntegrationViaRouter(userA); + const listingId = await seedListingViaRouter(userA, integrationId); + + // Try to update with regular route - protected fields should be stripped + await userA.clients.fullAuth.appStore.updateListing({ + listingId, + updates: { + display_name: 'Updated Name', + // app_listing_status and promotion_level are not accepted by the validator + }, + }); + + const listing = await readAppStoreListingById(listingId); + expect(listing?.display_name).toBe('Updated Name'); + // Protected fields should remain unchanged + expect(listing?.app_listing_status).toBe('DRAFT'); + expect(listing?.promotion_level).toBe('STANDARD'); + }); + }); + }); }); diff --git a/services/learn-card-network/brain-service/vite.config.ts b/services/learn-card-network/brain-service/vite.config.ts index f4f7367c7a..3aae5eb9ce 100644 --- a/services/learn-card-network/brain-service/vite.config.ts +++ b/services/learn-card-network/brain-service/vite.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ env: { IS_E2E_TEST: 'true', LOGIN_PROVIDER_DID: 'did:key:z6Mko9uYxDPk2BetRRziLz1xHN8nR5zQWdNjytKNDPcygHJP', + APP_STORE_ADMIN_PROFILE_IDS: 'app-store-admin', } }, }); From a126190aa1d2606a70f881d5b864958acf0e3f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacks=C3=B3n=20Smith?= Date: Tue, 25 Nov 2025 16:19:49 -0500 Subject: [PATCH 004/165] Add LCN Plugin support for app store --- packages/learn-card-types/src/lcn.ts | 75 +++++++++++++ .../plugins/learn-card-network/src/plugin.ts | 103 ++++++++++++++++++ .../plugins/learn-card-network/src/types.ts | 49 +++++++++ 3 files changed, 227 insertions(+) diff --git a/packages/learn-card-types/src/lcn.ts b/packages/learn-card-types/src/lcn.ts index 3b0ce55fcb..56ad9968b0 100644 --- a/packages/learn-card-types/src/lcn.ts +++ b/packages/learn-card-types/src/lcn.ts @@ -1459,3 +1459,78 @@ export type FrameworkWithSkills = z.infer; // Aliases used by the plugin type definitions export type CreateSkillTreeInput = SkillTreeInput; + +// App Store Listing +export const AppListingStatusValidator = z.enum(['DRAFT', 'PENDING_REVIEW', 'LISTED', 'ARCHIVED']); +export type AppListingStatus = z.infer; + +export const LaunchTypeValidator = z.enum([ + 'EMBEDDED_IFRAME', + 'SECOND_SCREEN', + 'DIRECT_LINK', + 'CONSENT_REDIRECT', + 'SERVER_HEADLESS', +]); +export type LaunchType = z.infer; + +export const PromotionLevelValidator = z.enum([ + 'FEATURED_CAROUSEL', + 'CURATED_LIST', + 'STANDARD', + 'DEMOTED', +]); +export type PromotionLevel = z.infer; + +export const AppStoreListingValidator = z.object({ + listing_id: z.string(), + display_name: z.string(), + tagline: z.string(), + full_description: z.string(), + icon_url: z.string(), + app_listing_status: AppListingStatusValidator, + launch_type: LaunchTypeValidator, + launch_config_json: z.string(), + category: z.string().optional(), + promo_video_url: z.string().optional(), + promotion_level: PromotionLevelValidator.optional(), + ios_app_store_id: z.string().optional(), + android_app_store_id: z.string().optional(), + privacy_policy_url: z.string().optional(), + terms_url: z.string().optional(), +}); + +export type AppStoreListing = z.infer; + +export const AppStoreListingCreateValidator = AppStoreListingValidator.omit({ + listing_id: true, + app_listing_status: true, + promotion_level: true, +}); + +export type AppStoreListingCreateType = z.infer; + +export const AppStoreListingUpdateValidator = AppStoreListingValidator.partial().omit({ + listing_id: true, + app_listing_status: true, + promotion_level: true, +}); + +export type AppStoreListingUpdateType = z.infer; + +export const InstalledAppValidator = AppStoreListingValidator.extend({ + installed_at: z.string(), +}); + +export type InstalledApp = z.infer; + +export const PaginatedAppStoreListingsValidator = PaginationResponseValidator.extend({ + records: AppStoreListingValidator.array(), +}); + +export type PaginatedAppStoreListings = z.infer; + +export const PaginatedInstalledAppsValidator = PaginationResponseValidator.extend({ + records: InstalledAppValidator.array(), +}); + +export type PaginatedInstalledApps = z.infer; diff --git a/packages/plugins/learn-card-network/src/plugin.ts b/packages/plugins/learn-card-network/src/plugin.ts index 7812d1581a..d6f6f9ba05 100644 --- a/packages/plugins/learn-card-network/src/plugin.ts +++ b/packages/plugins/learn-card-network/src/plugin.ts @@ -1446,6 +1446,109 @@ export async function getLearnCardNetworkPlugin( return client.integrations.associateIntegrationWithSigningAuthority.mutate({ integrationId, endpoint, name, did, isPrimary }); }, + // App Store + createAppStoreListing: async (_learnCard, integrationId, listing) => { + await ensureUser(); + + return client.appStore.createListing.mutate({ integrationId, listing }); + }, + + getAppStoreListing: async (_learnCard, listingId) => { + await ensureUser(); + + return client.appStore.getListing.query({ listingId }); + }, + + updateAppStoreListing: async (_learnCard, listingId, updates) => { + await ensureUser(); + + return client.appStore.updateListing.mutate({ listingId, updates }); + }, + + deleteAppStoreListing: async (_learnCard, listingId) => { + await ensureUser(); + + return client.appStore.deleteListing.mutate({ listingId }); + }, + + getListingsForIntegration: async (_learnCard, integrationId, options = {}) => { + await ensureUser(); + + return client.appStore.getListingsForIntegration.query({ integrationId, ...options }); + }, + + countListingsForIntegration: async (_learnCard, integrationId) => { + await ensureUser(); + + return client.appStore.countListingsForIntegration.query({ integrationId }); + }, + + browseAppStore: async (_learnCard, options) => { + return client.appStore.browseListedApps.query(options); + }, + + getPublicAppStoreListing: async (_learnCard, listingId) => { + return client.appStore.getPublicListing.query({ listingId }); + }, + + getAppStoreListingInstallCount: async (_learnCard, listingId) => { + return client.appStore.getListingInstallCount.query({ listingId }); + }, + + installApp: async (_learnCard, listingId) => { + await ensureUser(); + + return client.appStore.installApp.mutate({ listingId }); + }, + + uninstallApp: async (_learnCard, listingId) => { + await ensureUser(); + + return client.appStore.uninstallApp.mutate({ listingId }); + }, + + getInstalledApps: async (_learnCard, options = {}) => { + await ensureUser(); + + return client.appStore.getInstalledApps.query(options); + }, + + countInstalledApps: async _learnCard => { + await ensureUser(); + + return client.appStore.countInstalledApps.query(); + }, + + isAppInstalled: async (_learnCard, listingId) => { + await ensureUser(); + + return client.appStore.isAppInstalled.query({ listingId }); + }, + + isAppStoreAdmin: async _learnCard => { + await ensureUser(); + + return client.appStore.isAdmin.query(); + }, + + adminUpdateListingStatus: async (_learnCard, listingId, status) => { + await ensureUser(); + + return client.appStore.adminUpdateListingStatus.mutate({ listingId, status }); + }, + + adminUpdatePromotionLevel: async (_learnCard, listingId, promotionLevel) => { + await ensureUser(); + + return client.appStore.adminUpdatePromotionLevel.mutate({ listingId, promotionLevel }); + }, + + adminGetAllListings: async (_learnCard, options) => { + await ensureUser(); + + return client.appStore.adminGetAllListings.query(options); + }, + resolveFromLCN: async (_learnCard, uri) => { const result = await client.storage.resolve.query({ uri }); diff --git a/packages/plugins/learn-card-network/src/types.ts b/packages/plugins/learn-card-network/src/types.ts index b7d37baf75..89c483724e 100644 --- a/packages/plugins/learn-card-network/src/types.ts +++ b/packages/plugins/learn-card-network/src/types.ts @@ -84,6 +84,14 @@ import { LCNIntegrationUpdateType, LCNIntegrationQueryType, PaginatedLCNIntegrationsType, + // App Store + AppStoreListing, + AppStoreListingCreateType, + AppStoreListingUpdateType, + AppListingStatus, + PromotionLevel, + PaginatedAppStoreListings, + PaginatedInstalledApps, } from '@learncard/types'; import { Plugin } from '@learncard/core'; import { ProofOptions } from '@learncard/didkit-plugin'; @@ -610,6 +618,47 @@ export type LearnCardNetworkPluginMethods = { isPrimary?: boolean ) => Promise; + // App Store + createAppStoreListing: ( + integrationId: string, + listing: AppStoreListingCreateType + ) => Promise; + getAppStoreListing: (listingId: string) => Promise; + updateAppStoreListing: ( + listingId: string, + updates: AppStoreListingUpdateType + ) => Promise; + deleteAppStoreListing: (listingId: string) => Promise; + getListingsForIntegration: ( + integrationId: string, + options?: Partial + ) => Promise; + countListingsForIntegration: (integrationId: string) => Promise; + + browseAppStore: (options?: { + limit?: number; + cursor?: string; + category?: string; + promotionLevel?: PromotionLevel; + }) => Promise; + getPublicAppStoreListing: (listingId: string) => Promise; + getAppStoreListingInstallCount: (listingId: string) => Promise; + + installApp: (listingId: string) => Promise; + uninstallApp: (listingId: string) => Promise; + getInstalledApps: (options?: Partial) => Promise; + countInstalledApps: () => Promise; + isAppInstalled: (listingId: string) => Promise; + + isAppStoreAdmin: () => Promise; + adminUpdateListingStatus: (listingId: string, status: AppListingStatus) => Promise; + adminUpdatePromotionLevel: (listingId: string, promotionLevel: PromotionLevel) => Promise; + adminGetAllListings: (options?: { + limit?: number; + cursor?: string; + status?: AppListingStatus; + }) => Promise; + resolveFromLCN: ( uri: string ) => Promise; From eedda7ec82fa2fe256f2dad9299af2e21b3f8b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacks=C3=B3n=20Smith?= Date: Tue, 25 Nov 2025 16:44:05 -0500 Subject: [PATCH 005/165] Add app-store-portal example app --- .../app-store-portal/.astro/settings.json | 5 + examples/app-store-portal/.astro/types.d.ts | 1 + examples/app-store-portal/README.md | 121 ++++ examples/app-store-portal/astro.config.mjs | 7 + examples/app-store-portal/package.json | 27 + examples/app-store-portal/public/favicon.svg | 11 + .../app-store-portal/src/components/App.tsx | 39 ++ .../src/components/admin/AdminDashboard.tsx | 150 +++++ .../src/components/admin/ListingCard.tsx | 69 +++ .../src/components/admin/ListingDetail.tsx | 290 +++++++++ .../src/components/partner/AppDetailsStep.tsx | 170 ++++++ .../components/partner/LaunchConfigStep.tsx | 286 +++++++++ .../src/components/partner/LaunchTypeStep.tsx | 112 ++++ .../src/components/partner/ReviewStep.tsx | 166 ++++++ .../src/components/partner/SubmissionForm.tsx | 203 +++++++ .../src/components/ui/Header.tsx | 50 ++ .../src/components/ui/StatusBadge.tsx | 22 + .../src/components/ui/StepIndicator.tsx | 60 ++ .../src/data/mock-listings.ts | 100 ++++ examples/app-store-portal/src/env.d.ts | 2 + .../app-store-portal/src/layouts/Layout.astro | 22 + .../app-store-portal/src/pages/index.astro | 9 + .../app-store-portal/src/styles/global.css | 128 ++++ .../app-store-portal/src/types/app-store.ts | 155 +++++ examples/app-store-portal/tailwind.config.mjs | 47 ++ examples/app-store-portal/tsconfig.json | 11 + pnpm-lock.yaml | 558 ++++++++++++++++-- 27 files changed, 2775 insertions(+), 46 deletions(-) create mode 100644 examples/app-store-portal/.astro/settings.json create mode 100644 examples/app-store-portal/.astro/types.d.ts create mode 100644 examples/app-store-portal/README.md create mode 100644 examples/app-store-portal/astro.config.mjs create mode 100644 examples/app-store-portal/package.json create mode 100644 examples/app-store-portal/public/favicon.svg create mode 100644 examples/app-store-portal/src/components/App.tsx create mode 100644 examples/app-store-portal/src/components/admin/AdminDashboard.tsx create mode 100644 examples/app-store-portal/src/components/admin/ListingCard.tsx create mode 100644 examples/app-store-portal/src/components/admin/ListingDetail.tsx create mode 100644 examples/app-store-portal/src/components/partner/AppDetailsStep.tsx create mode 100644 examples/app-store-portal/src/components/partner/LaunchConfigStep.tsx create mode 100644 examples/app-store-portal/src/components/partner/LaunchTypeStep.tsx create mode 100644 examples/app-store-portal/src/components/partner/ReviewStep.tsx create mode 100644 examples/app-store-portal/src/components/partner/SubmissionForm.tsx create mode 100644 examples/app-store-portal/src/components/ui/Header.tsx create mode 100644 examples/app-store-portal/src/components/ui/StatusBadge.tsx create mode 100644 examples/app-store-portal/src/components/ui/StepIndicator.tsx create mode 100644 examples/app-store-portal/src/data/mock-listings.ts create mode 100644 examples/app-store-portal/src/env.d.ts create mode 100644 examples/app-store-portal/src/layouts/Layout.astro create mode 100644 examples/app-store-portal/src/pages/index.astro create mode 100644 examples/app-store-portal/src/styles/global.css create mode 100644 examples/app-store-portal/src/types/app-store.ts create mode 100644 examples/app-store-portal/tailwind.config.mjs create mode 100644 examples/app-store-portal/tsconfig.json diff --git a/examples/app-store-portal/.astro/settings.json b/examples/app-store-portal/.astro/settings.json new file mode 100644 index 0000000000..c8cd09a171 --- /dev/null +++ b/examples/app-store-portal/.astro/settings.json @@ -0,0 +1,5 @@ +{ + "_variables": { + "lastUpdateCheck": 1764106513962 + } +} \ No newline at end of file diff --git a/examples/app-store-portal/.astro/types.d.ts b/examples/app-store-portal/.astro/types.d.ts new file mode 100644 index 0000000000..f964fe0cff --- /dev/null +++ b/examples/app-store-portal/.astro/types.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/app-store-portal/README.md b/examples/app-store-portal/README.md new file mode 100644 index 0000000000..9dd0951852 --- /dev/null +++ b/examples/app-store-portal/README.md @@ -0,0 +1,121 @@ +# LearnCard App Store Portal + +A mock-up of the LearnCard App Store submission and management interface, designed to validate the App Store API and bootstrap initial content. + +## Features + +### Partner Submission Mode +- Multi-step form for app submissions +- App details (name, tagline, description, icon) +- Launch type selection (Embedded Iframe, Second Screen, Direct Link, Consent Flow, Server Headless) +- Conditional launch configuration based on selected type +- Review and submit for approval + +### Admin Approval Mode +- Review queue for pending submissions +- Filter by status (Pending, Listed, Draft, Archived) +- Search functionality +- Detailed view with launch configuration inspection +- Security warnings for sensitive configurations +- Approve/Reject actions +- Promotion level management for listed apps + +## Tech Stack + +- **Framework**: [Astro](https://astro.build/) with React +- **Styling**: TailwindCSS with Apple-inspired design tokens +- **Icons**: Lucide React +- **State**: React useState (mock data, no backend) + +## Getting Started + +```bash +# Install dependencies +pnpm install + +# Start development server +pnpm dev + +# Build for production +pnpm build +``` + +## Project Structure + +``` +src/ +├── components/ +│ ├── admin/ # Admin dashboard components +│ │ ├── AdminDashboard.tsx +│ │ ├── ListingCard.tsx +│ │ └── ListingDetail.tsx +│ ├── partner/ # Partner submission components +│ │ ├── AppDetailsStep.tsx +│ │ ├── LaunchTypeStep.tsx +│ │ ├── LaunchConfigStep.tsx +│ │ ├── ReviewStep.tsx +│ │ └── SubmissionForm.tsx +│ ├── ui/ # Shared UI components +│ │ ├── Header.tsx +│ │ ├── StepIndicator.tsx +│ │ └── StatusBadge.tsx +│ └── App.tsx # Main application component +├── data/ +│ └── mock-listings.ts # Sample app listings data +├── layouts/ +│ └── Layout.astro +├── pages/ +│ └── index.astro +├── styles/ +│ └── global.css +└── types/ + └── app-store.ts # TypeScript types +``` + +## API Integration + +This portal is designed to test the LearnCard App Store API. The mock data in `src/data/mock-listings.ts` mirrors the actual API schema: + +```typescript +interface AppStoreListing { + listing_id: string; + display_name: string; + tagline: string; + full_description: string; + icon_url: string; + app_listing_status: 'DRAFT' | 'PENDING_REVIEW' | 'LISTED' | 'ARCHIVED'; + launch_type: 'EMBEDDED_IFRAME' | 'SECOND_SCREEN' | 'DIRECT_LINK' | 'CONSENT_REDIRECT' | 'SERVER_HEADLESS'; + launch_config_json: string; + category?: string; + promo_video_url?: string; + promotion_level?: 'FEATURED_CAROUSEL' | 'CURATED_LIST' | 'STANDARD' | 'DEMOTED'; + privacy_policy_url?: string; + terms_url?: string; +} +``` + +To connect to the real API, replace the mock data imports with actual API calls using the LearnCard Network plugin: + +```typescript +import { learnCard } from '@learncard/init'; + +// Create listing +const listingId = await learnCard.invoke.createAppStoreListing(integrationId, { + display_name: 'My App', + tagline: 'A great app', + // ... +}); + +// Admin operations +const isAdmin = await learnCard.invoke.isAppStoreAdmin(); +await learnCard.invoke.adminUpdateListingStatus(listingId, 'LISTED'); +``` + +## Design Philosophy + +This interface is inspired by Apple's App Store Connect, focusing on: + +- **Clarity**: Clean typography and generous whitespace +- **Guidance**: Step-by-step flow with progress indicators +- **Security**: Prominent display of security-critical configurations +- **Efficiency**: Quick actions for common admin tasks diff --git a/examples/app-store-portal/astro.config.mjs b/examples/app-store-portal/astro.config.mjs new file mode 100644 index 0000000000..11d155b931 --- /dev/null +++ b/examples/app-store-portal/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; +import tailwind from '@astrojs/tailwind'; + +export default defineConfig({ + integrations: [react(), tailwind()], +}); diff --git a/examples/app-store-portal/package.json b/examples/app-store-portal/package.json new file mode 100644 index 0000000000..42df44fb42 --- /dev/null +++ b/examples/app-store-portal/package.json @@ -0,0 +1,27 @@ +{ + "name": "app-store-portal", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/check": "^0.9.4", + "@astrojs/react": "^3.6.2", + "@astrojs/tailwind": "^5.1.2", + "astro": "^4.16.7", + "lucide-react": "^0.460.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1" + } +} diff --git a/examples/app-store-portal/public/favicon.svg b/examples/app-store-portal/public/favicon.svg new file mode 100644 index 0000000000..139a59c154 --- /dev/null +++ b/examples/app-store-portal/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/examples/app-store-portal/src/components/App.tsx b/examples/app-store-portal/src/components/App.tsx new file mode 100644 index 0000000000..ed970c217e --- /dev/null +++ b/examples/app-store-portal/src/components/App.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; +import { Header } from './ui/Header'; +import { SubmissionForm } from './partner/SubmissionForm'; +import { AdminDashboard } from './admin/AdminDashboard'; + +type Mode = 'partner' | 'admin'; + +export const App: React.FC = () => { + const [mode, setMode] = useState('partner'); + + return ( +
+
+ +
+ {mode === 'partner' ? ( +
+
+

+ Submit Your App +

+ +

+ Join the LearnCard ecosystem. Submit your application and reach + millions of users managing their digital credentials. +

+
+ + +
+ ) : ( + + )} +
+
+ ); +}; + +export default App; diff --git a/examples/app-store-portal/src/components/admin/AdminDashboard.tsx b/examples/app-store-portal/src/components/admin/AdminDashboard.tsx new file mode 100644 index 0000000000..5e2d5851c3 --- /dev/null +++ b/examples/app-store-portal/src/components/admin/AdminDashboard.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; +import { Search, Filter, Inbox } from 'lucide-react'; +import type { AppStoreListing, AppListingStatus, PromotionLevel } from '../../types/app-store'; +import { MOCK_LISTINGS } from '../../data/mock-listings'; +import { ListingCard } from './ListingCard'; +import { ListingDetail } from './ListingDetail'; + +type FilterStatus = AppListingStatus | 'ALL'; + +export const AdminDashboard: React.FC = () => { + const [listings, setListings] = useState(MOCK_LISTINGS); + const [selectedListing, setSelectedListing] = useState(null); + const [filterStatus, setFilterStatus] = useState('PENDING_REVIEW'); + const [searchQuery, setSearchQuery] = useState(''); + + const filteredListings = listings.filter(listing => { + const matchesStatus = filterStatus === 'ALL' || listing.app_listing_status === filterStatus; + + const matchesSearch = + !searchQuery || + listing.display_name.toLowerCase().includes(searchQuery.toLowerCase()) || + listing.tagline.toLowerCase().includes(searchQuery.toLowerCase()); + + return matchesStatus && matchesSearch; + }); + + const pendingCount = listings.filter(l => l.app_listing_status === 'PENDING_REVIEW').length; + + const handleStatusChange = (listingId: string, status: AppListingStatus) => { + setListings(prev => + prev.map(listing => + listing.listing_id === listingId + ? { ...listing, app_listing_status: status } + : listing + ) + ); + + if (selectedListing?.listing_id === listingId) { + setSelectedListing(prev => (prev ? { ...prev, app_listing_status: status } : null)); + } + }; + + const handlePromotionChange = (listingId: string, level: PromotionLevel) => { + setListings(prev => + prev.map(listing => + listing.listing_id === listingId ? { ...listing, promotion_level: level } : listing + ) + ); + + if (selectedListing?.listing_id === listingId) { + setSelectedListing(prev => (prev ? { ...prev, promotion_level: level } : null)); + } + }; + + return ( +
+ {/* Sidebar - Listings List */} +
+ {/* Search and Filters */} +
+
+ + + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 bg-apple-gray-100 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-apple-blue" + /> +
+ +
+ {( + [ + { value: 'PENDING_REVIEW', label: 'Pending' }, + { value: 'ALL', label: 'All' }, + { value: 'LISTED', label: 'Listed' }, + { value: 'DRAFT', label: 'Draft' }, + { value: 'ARCHIVED', label: 'Archived' }, + ] as { value: FilterStatus; label: string }[] + ).map(filter => ( + + ))} +
+
+ + {/* Listings */} +
+ {filteredListings.length === 0 ? ( +
+ + +

No listings found

+
+ ) : ( + filteredListings.map(listing => ( + + )) + )} +
+
+ + {/* Main Content - Detail View */} +
+ {selectedListing ? ( + + ) : ( +
+
+ + +

+ Select an app to review +

+ +

+ Choose a listing from the sidebar to view details +

+
+
+ )} +
+
+ ); +}; diff --git a/examples/app-store-portal/src/components/admin/ListingCard.tsx b/examples/app-store-portal/src/components/admin/ListingCard.tsx new file mode 100644 index 0000000000..b10f54e307 --- /dev/null +++ b/examples/app-store-portal/src/components/admin/ListingCard.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { ExternalLink, Clock } from 'lucide-react'; +import type { AppStoreListing } from '../../types/app-store'; +import { LAUNCH_TYPE_INFO, CATEGORY_OPTIONS } from '../../types/app-store'; +import { StatusBadge } from '../ui/StatusBadge'; + +interface ListingCardProps { + listing: AppStoreListing; + onSelect: (listing: AppStoreListing) => void; + isSelected: boolean; +} + +export const ListingCard: React.FC = ({ listing, onSelect, isSelected }) => { + const launchTypeInfo = LAUNCH_TYPE_INFO[listing.launch_type]; + const categoryLabel = CATEGORY_OPTIONS.find(c => c.value === listing.category)?.label; + + return ( + + ); +}; diff --git a/examples/app-store-portal/src/components/admin/ListingDetail.tsx b/examples/app-store-portal/src/components/admin/ListingDetail.tsx new file mode 100644 index 0000000000..92a1ce007e --- /dev/null +++ b/examples/app-store-portal/src/components/admin/ListingDetail.tsx @@ -0,0 +1,290 @@ +import React, { useState } from 'react'; +import { + ExternalLink, + Code, + ShieldAlert, + CheckCircle, + XCircle, + Loader2, + Star, + TrendingUp, + Minus, + ArrowDown, +} from 'lucide-react'; +import type { AppStoreListing, PromotionLevel } from '../../types/app-store'; +import { LAUNCH_TYPE_INFO, PROMOTION_LEVEL_INFO, CATEGORY_OPTIONS } from '../../types/app-store'; +import { StatusBadge } from '../ui/StatusBadge'; + +interface ListingDetailProps { + listing: AppStoreListing; + onStatusChange: (listingId: string, status: AppStoreListing['app_listing_status']) => void; + onPromotionChange: (listingId: string, level: PromotionLevel) => void; +} + +export const ListingDetail: React.FC = ({ + listing, + onStatusChange, + onPromotionChange, +}) => { + const [isApproving, setIsApproving] = useState(false); + const [isRejecting, setIsRejecting] = useState(false); + const [showPromotionMenu, setShowPromotionMenu] = useState(false); + + const launchTypeInfo = LAUNCH_TYPE_INFO[listing.launch_type]; + const categoryLabel = CATEGORY_OPTIONS.find(c => c.value === listing.category)?.label; + + let parsedConfig: Record = {}; + try { + parsedConfig = JSON.parse(listing.launch_config_json); + } catch { + // Keep empty + } + + const sandbox = Array.isArray(parsedConfig.sandbox) ? parsedConfig.sandbox : []; + + const handleApprove = async () => { + setIsApproving(true); + await new Promise(resolve => setTimeout(resolve, 1000)); + onStatusChange(listing.listing_id, 'LISTED'); + setIsApproving(false); + }; + + const handleReject = async () => { + setIsRejecting(true); + await new Promise(resolve => setTimeout(resolve, 1000)); + onStatusChange(listing.listing_id, 'ARCHIVED'); + setIsRejecting(false); + }; + + const promotionIcons: Record> = { + FEATURED_CAROUSEL: Star, + CURATED_LIST: TrendingUp, + STANDARD: Minus, + DEMOTED: ArrowDown, + }; + + return ( +
+ {/* Header */} +
+
+
+ {listing.display_name} +
+ +
+
+
+

+ {listing.display_name} +

+ +

{listing.tagline}

+
+ + +
+ +
+ {categoryLabel && ( + + {categoryLabel} + + )} + + + {launchTypeInfo.label} + +
+
+
+
+ + {/* Content */} +
+ {/* Description */} +
+

Description

+ +

+ {listing.full_description} +

+
+ + {/* Launch Configuration */} +
+
+ + +

+ Launch Configuration (Security Review) +

+
+ +
+
+                            {JSON.stringify(parsedConfig, null, 2)}
+                        
+
+ + {/* Security Warnings */} + {listing.launch_type === 'EMBEDDED_IFRAME' && + sandbox.includes('allow-same-origin') && ( +
+
+ + + + Security Note: allow-same-origin is + enabled. Verify this is required for the app's + functionality. + +
+
+ )} +
+ + {/* Links */} +
+

External Links

+ +
+ {parsedConfig.url && ( + + + Application URL + + )} + + {listing.privacy_policy_url && ( + + + Privacy Policy + + )} + + {listing.terms_url && ( + + + Terms of Service + + )} +
+
+ + {/* Promotion Level */} + {listing.app_listing_status === 'LISTED' && ( +
+

+ Promotion Level +

+ +
+ + + {showPromotionMenu && ( +
+ {(Object.entries(PROMOTION_LEVEL_INFO) as [PromotionLevel, typeof PROMOTION_LEVEL_INFO[PromotionLevel]][]).map( + ([level, info]) => { + const Icon = promotionIcons[level]; + + return ( + + ); + } + )} +
+ )} +
+
+ )} +
+ + {/* Actions */} + {listing.app_listing_status === 'PENDING_REVIEW' && ( +
+
+ + + +
+
+ )} +
+ ); +}; diff --git a/examples/app-store-portal/src/components/partner/AppDetailsStep.tsx b/examples/app-store-portal/src/components/partner/AppDetailsStep.tsx new file mode 100644 index 0000000000..ffae714467 --- /dev/null +++ b/examples/app-store-portal/src/components/partner/AppDetailsStep.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import { Image, Info } from 'lucide-react'; +import type { AppStoreListingCreate } from '../../types/app-store'; +import { CATEGORY_OPTIONS } from '../../types/app-store'; + +interface AppDetailsStepProps { + data: Partial; + onChange: (data: Partial) => void; + errors: Record; +} + +export const AppDetailsStep: React.FC = ({ data, onChange, errors }) => { + const handleChange = (field: keyof AppStoreListingCreate, value: string) => { + onChange({ ...data, [field]: value }); + }; + + return ( +
+
+

App Information

+ +

+ Tell us about your application. This information will be displayed to users. +

+
+ + {/* Icon Preview */} +
+
+ {data.icon_url ? ( + App icon + ) : ( + + )} +
+ +
+ + + handleChange('icon_url', e.target.value)} + placeholder="https://example.com/icon.png" + className={`input ${errors.icon_url ? 'input-error' : ''}`} + /> + + {errors.icon_url && ( +

{errors.icon_url}

+ )} +
+
+ + {/* Display Name */} +
+ + + handleChange('display_name', e.target.value)} + placeholder="My Amazing App" + className={`input ${errors.display_name ? 'input-error' : ''}`} + maxLength={50} + /> + +
+ {errors.display_name ? ( +

{errors.display_name}

+ ) : ( + + )} + + + {(data.display_name || '').length}/50 + +
+
+ + {/* Tagline */} +
+ + + handleChange('tagline', e.target.value)} + placeholder="A short, catchy description of your app" + className={`input ${errors.tagline ? 'input-error' : ''}`} + maxLength={100} + /> + +
+ {errors.tagline ? ( +

{errors.tagline}

+ ) : ( + + )} + + + {(data.tagline || '').length}/100 + +
+
+ + {/* Full Description */} +
+ + +