@open-loyalty/mcp-server 1.1.0 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +180 -177
  2. package/dist/auth/provider.js +2 -14
  3. package/dist/auth/storage.js +22 -0
  4. package/dist/client/http.js +10 -0
  5. package/dist/config.d.ts +0 -13
  6. package/dist/config.js +0 -14
  7. package/dist/http.js +35 -3
  8. package/dist/instructions.d.ts +5 -0
  9. package/dist/instructions.js +440 -0
  10. package/dist/prompts/fan-engagement-setup.d.ts +107 -0
  11. package/dist/prompts/fan-engagement-setup.js +492 -0
  12. package/dist/server.d.ts +1 -1
  13. package/dist/server.js +60 -273
  14. package/dist/tools/achievement/handlers.d.ts +117 -0
  15. package/dist/tools/achievement/handlers.js +161 -0
  16. package/dist/tools/achievement/index.d.ts +479 -0
  17. package/dist/tools/achievement/index.js +74 -0
  18. package/dist/tools/achievement/schemas.d.ts +433 -0
  19. package/dist/tools/achievement/schemas.js +142 -0
  20. package/dist/tools/achievement.d.ts +141 -121
  21. package/dist/tools/achievement.js +60 -24
  22. package/dist/tools/admin.d.ts +6 -6
  23. package/dist/tools/admin.js +12 -12
  24. package/dist/tools/analytics.d.ts +11 -11
  25. package/dist/tools/analytics.js +30 -29
  26. package/dist/tools/apikey.d.ts +3 -3
  27. package/dist/tools/apikey.js +6 -6
  28. package/dist/tools/audit.d.ts +2 -2
  29. package/dist/tools/audit.js +4 -4
  30. package/dist/tools/badge.d.ts +6 -6
  31. package/dist/tools/badge.js +23 -18
  32. package/dist/tools/campaign/handlers.d.ts +42 -0
  33. package/dist/tools/campaign/handlers.js +223 -0
  34. package/dist/tools/campaign/index.d.ts +783 -0
  35. package/dist/tools/campaign/index.js +117 -0
  36. package/dist/tools/campaign/member-handlers.d.ts +60 -0
  37. package/dist/tools/campaign/member-handlers.js +159 -0
  38. package/dist/tools/campaign/schemas.d.ts +704 -0
  39. package/dist/tools/campaign/schemas.js +259 -0
  40. package/dist/tools/campaign/types.d.ts +161 -0
  41. package/dist/tools/campaign/types.js +2 -0
  42. package/dist/tools/custom-event.d.ts +315 -0
  43. package/dist/tools/custom-event.js +270 -0
  44. package/dist/tools/export.d.ts +4 -4
  45. package/dist/tools/export.js +12 -12
  46. package/dist/tools/import.d.ts +3 -3
  47. package/dist/tools/import.js +23 -15
  48. package/dist/tools/index.js +13 -5
  49. package/dist/tools/member/handlers.d.ts +111 -0
  50. package/dist/tools/member/handlers.js +206 -0
  51. package/dist/tools/member/index.d.ts +169 -0
  52. package/dist/tools/member/index.js +92 -0
  53. package/dist/tools/member/schemas.d.ts +89 -0
  54. package/dist/tools/member/schemas.js +65 -0
  55. package/dist/tools/points.d.ts +7 -6
  56. package/dist/tools/points.js +21 -20
  57. package/dist/tools/referral/handlers.d.ts +47 -0
  58. package/dist/tools/referral/handlers.js +115 -0
  59. package/dist/tools/referral/index.d.ts +44 -0
  60. package/dist/tools/referral/index.js +44 -0
  61. package/dist/tools/referral/schemas.d.ts +34 -0
  62. package/dist/tools/referral/schemas.js +52 -0
  63. package/dist/tools/reward/handlers.d.ts +110 -0
  64. package/dist/tools/reward/handlers.js +289 -0
  65. package/dist/tools/reward/index.d.ts +177 -0
  66. package/dist/tools/reward/index.js +93 -0
  67. package/dist/tools/reward/schemas.d.ts +116 -0
  68. package/dist/tools/reward/schemas.js +92 -0
  69. package/dist/tools/role.d.ts +6 -6
  70. package/dist/tools/role.js +12 -12
  71. package/dist/tools/segment/handlers.d.ts +87 -0
  72. package/dist/tools/segment/handlers.js +174 -0
  73. package/dist/tools/segment/index.d.ts +395 -0
  74. package/dist/tools/segment/index.js +88 -0
  75. package/dist/tools/segment/schemas.d.ts +337 -0
  76. package/dist/tools/segment/schemas.js +79 -0
  77. package/dist/tools/segment.d.ts +10 -10
  78. package/dist/tools/segment.js +55 -31
  79. package/dist/tools/store.d.ts +4 -4
  80. package/dist/tools/store.js +8 -8
  81. package/dist/tools/tierset.d.ts +10 -10
  82. package/dist/tools/tierset.js +69 -37
  83. package/dist/tools/transaction.d.ts +4 -4
  84. package/dist/tools/transaction.js +12 -12
  85. package/dist/tools/wallet-type.d.ts +221 -16
  86. package/dist/tools/wallet-type.js +248 -17
  87. package/dist/tools/webhook.d.ts +6 -6
  88. package/dist/tools/webhook.js +90 -31
  89. package/dist/types/schemas/achievement.d.ts +18 -18
  90. package/dist/types/schemas/campaign.d.ts +64 -184
  91. package/dist/types/schemas/campaign.js +2 -7
  92. package/dist/types/schemas/common.d.ts +5 -0
  93. package/dist/types/schemas/common.js +5 -0
  94. package/dist/types/schemas/member.d.ts +2 -2
  95. package/dist/types/schemas/reward.d.ts +94 -18
  96. package/dist/types/schemas/reward.js +8 -3
  97. package/dist/types/schemas/wallet-type.d.ts +306 -8
  98. package/dist/types/schemas/wallet-type.js +82 -1
  99. package/dist/utils/errors.js +32 -5
  100. package/dist/workflows/app-login-streak.d.ts +39 -0
  101. package/dist/workflows/app-login-streak.js +298 -0
  102. package/dist/workflows/early-arrival.d.ts +33 -0
  103. package/dist/workflows/early-arrival.js +148 -0
  104. package/dist/workflows/index.d.ts +101 -0
  105. package/dist/workflows/index.js +208 -0
  106. package/dist/workflows/match-attendance.d.ts +45 -0
  107. package/dist/workflows/match-attendance.js +308 -0
  108. package/dist/workflows/sportsbar-visit.d.ts +41 -0
  109. package/dist/workflows/sportsbar-visit.js +284 -0
  110. package/dist/workflows/vod-watching.d.ts +43 -0
  111. package/dist/workflows/vod-watching.js +326 -0
  112. package/package.json +8 -2
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  export const WalletTypeSchema = z.object({
3
3
  walletTypeId: z.string(),
4
4
  code: z.string(),
5
- name: z.string(),
5
+ name: z.string().optional(), // Optional: single wallet type endpoint may not return name
6
6
  unitSingularName: z.string().optional(),
7
7
  unitPluralName: z.string().optional(),
8
8
  active: z.boolean(),
@@ -10,8 +10,89 @@ export const WalletTypeSchema = z.object({
10
10
  createdAt: z.string().optional(),
11
11
  limits: z.record(z.unknown()).optional(),
12
12
  allowNegativeBalance: z.boolean().optional(),
13
+ translations: z.record(z.unknown()).optional(),
14
+ unitDaysExpiryAfter: z.string().optional(),
15
+ unitDaysActiveCount: z.number().optional(),
16
+ unitYearsActiveCount: z.number().optional(),
17
+ unitDaysLocked: z.number().optional(),
18
+ allTimeNotLocked: z.boolean().optional(),
19
+ unitExpiryDate: z.string().optional(),
13
20
  });
14
21
  // API returns { items: [...] } not just an array
15
22
  export const WalletTypeListResponseSchema = z.object({
16
23
  items: z.array(WalletTypeSchema),
17
24
  });
25
+ // Create response schema - API returns { walletTypeId: "..." }
26
+ export const WalletTypeCreateResponseSchema = z.object({
27
+ walletTypeId: z.string(),
28
+ });
29
+ // =============================================================================
30
+ // INPUT SCHEMAS (for MCP tool definitions)
31
+ // =============================================================================
32
+ export const WalletTypeListInputSchema = {
33
+ storeCode: z.string().optional().describe("Store code for multi-tenant routing. DO NOT pass this parameter - the configured default will be used automatically. Only provide a value if the user explicitly asks to work with a different store."),
34
+ };
35
+ export const WalletTypeGetInputSchema = {
36
+ storeCode: z.string().optional().describe("Store code for multi-tenant routing. DO NOT pass this parameter - the configured default will be used automatically. Only provide a value if the user explicitly asks to work with a different store."),
37
+ walletTypeId: z.string().describe("The wallet type ID (UUID) to retrieve."),
38
+ };
39
+ // Translations schema for wallet types
40
+ const WalletTypeTranslationsInputSchema = z.object({
41
+ en: z.object({
42
+ name: z.string().describe("Wallet type name in English (REQUIRED)."),
43
+ description: z.string().optional().describe("Description in English (optional)."),
44
+ }),
45
+ }).passthrough().describe("Translations object with at least 'en' key containing { name }.");
46
+ // Limits interval schema
47
+ const LimitsIntervalSchema = z.object({
48
+ type: z.enum(["calendarHours", "calendarDays", "calendarWeeks", "calendarMonths", "calendarYears"]).describe("Interval type. IMPORTANT: Use 'calendarDays' NOT 'days', 'calendarMonths' NOT 'months', etc."),
49
+ value: z.number().int().positive().describe("Interval value (e.g., 1 for 'every 1 calendarMonth')."),
50
+ });
51
+ // Points limit schema
52
+ const PointsLimitSchema = z.object({
53
+ interval: LimitsIntervalSchema.optional().describe("Interval for limit reset. Omit for lifetime/forever limits."),
54
+ value: z.number().int().nonnegative().describe("Maximum points value for this limit."),
55
+ }).optional();
56
+ // Limits schema
57
+ export const WalletTypeLimitsInputSchema = z.object({
58
+ points: PointsLimitSchema.describe("Global points limit across all members. Example: { interval: { type: 'calendarYears', value: 1 }, value: 100000 }"),
59
+ pointsPerMember: PointsLimitSchema.describe("Per-member points limit. Example: { interval: { type: 'calendarYears', value: 1 }, value: 10000 }"),
60
+ }).optional();
61
+ export const WalletTypeCreateInputSchema = {
62
+ storeCode: z.string().optional().describe("Store code for multi-tenant routing. DO NOT pass this parameter - the configured default will be used automatically. Only provide a value if the user explicitly asks to work with a different store."),
63
+ translations: WalletTypeTranslationsInputSchema.describe("Translations with at least 'en' key containing { name }. REQUIRED."),
64
+ unitSingularName: z.string().describe("Singular unit name (e.g., 'point', 'coin', 'star'). REQUIRED."),
65
+ unitPluralName: z.string().describe("Plural unit name (e.g., 'points', 'coins', 'stars'). REQUIRED."),
66
+ unitDaysExpiryAfter: z.string().describe("⚠️ REQUIRED. Days after earning before points expire. Use 'all_time_active' for never-expiring points, " +
67
+ "or a number string like '365' for points that expire 365 days after earning."),
68
+ code: z.string().optional().describe("Unique code for the wallet type (e.g., 'bonus_points', 'store_credit'). " +
69
+ "Use lowercase with underscores. If not provided, a code will be auto-generated. " +
70
+ "IMPORTANT: Cannot be changed after creation."),
71
+ allowNegativeBalance: z.boolean().optional().describe("Whether to allow negative balances (default: false). " +
72
+ "Set to true for credit-like systems where members can owe points."),
73
+ unitExpiryDate: z.string().optional().describe("Annual expiry date in 'MM-DD' format (e.g., '12-31' for end of year). " +
74
+ "Points earned during the year expire on this date. " +
75
+ "IMPORTANT: Use 'MM-DD' format, NOT 'YYYY-MM-DD'."),
76
+ unitDaysActiveCount: z.number().int().nonnegative().optional().describe("Number of days points remain active after earning. Use with unitDaysExpiryAfter."),
77
+ unitYearsActiveCount: z.number().int().nonnegative().optional().describe("Number of years points remain active after earning."),
78
+ unitDaysLocked: z.number().int().nonnegative().optional().describe("Number of days points are locked after earning before becoming spendable. " +
79
+ "Use 0 or omit for immediately available points."),
80
+ allTimeNotLocked: z.boolean().optional().describe("If true, points are never locked and immediately available. Overrides unitDaysLocked."),
81
+ };
82
+ export const WalletTypeUpdateInputSchema = {
83
+ storeCode: z.string().optional().describe("Store code for multi-tenant routing. DO NOT pass this parameter - the configured default will be used automatically. Only provide a value if the user explicitly asks to work with a different store."),
84
+ walletTypeId: z.string().describe("The wallet type ID (UUID) to update."),
85
+ name: z.string().optional().describe("Update the wallet type name (updates translations.en.name)."),
86
+ description: z.string().optional().describe("Update the description (updates translations.en.description)."),
87
+ unitSingularName: z.string().optional().describe("Update the singular unit name."),
88
+ unitPluralName: z.string().optional().describe("Update the plural unit name."),
89
+ active: z.boolean().optional().describe("Update active status."),
90
+ allowNegativeBalance: z.boolean().optional().describe("Update negative balance setting."),
91
+ limits: WalletTypeLimitsInputSchema.describe("Update points limits."),
92
+ unitExpiryDate: z.string().optional().describe("Update annual expiry date ('MM-DD' format)."),
93
+ unitDaysExpiryAfter: z.string().optional().describe("Update days until expiry or 'all_time_active'."),
94
+ unitDaysActiveCount: z.number().int().nonnegative().optional().describe("Update days active count."),
95
+ unitYearsActiveCount: z.number().int().nonnegative().optional().describe("Update years active count."),
96
+ unitDaysLocked: z.number().int().nonnegative().optional().describe("Update locked days."),
97
+ allTimeNotLocked: z.boolean().optional().describe("Update not-locked setting."),
98
+ };
@@ -1,4 +1,4 @@
1
- import { AxiosError } from "axios";
1
+ import axios from "axios";
2
2
  export class OpenLoyaltyError extends Error {
3
3
  code;
4
4
  hint;
@@ -15,7 +15,7 @@ export function formatApiError(error, relatedTool) {
15
15
  if (error instanceof OpenLoyaltyError) {
16
16
  return error;
17
17
  }
18
- if (error instanceof AxiosError) {
18
+ if (axios.isAxiosError(error)) {
19
19
  const status = error.response?.status;
20
20
  const data = error.response?.data;
21
21
  if (status === 404) {
@@ -31,6 +31,35 @@ export function formatApiError(error, relatedTool) {
31
31
  if (status === 400) {
32
32
  const errors = data?.errors;
33
33
  const errorDetails = errors?.map(e => `${e.path || ''}: ${e.message}`).join('; ') || '';
34
+ const fullMessage = data?.message
35
+ ? `${String(data.message)}${errorDetails ? ` (${errorDetails})` : ''}`
36
+ : "Invalid request data";
37
+ // Parse "extra fields" errors - usually means a field shouldn't be at top level
38
+ if (fullMessage.includes("extra fields") || fullMessage.includes("should not contain extra fields")) {
39
+ return new OpenLoyaltyError({
40
+ code: "VALIDATION_ERROR",
41
+ message: fullMessage,
42
+ hint: "The API rejected unexpected fields in your request. Common causes: " +
43
+ "1. A field name is misspelled, " +
44
+ "2. A field should be nested differently (e.g., badgeTypeId may not be supported at creation time), " +
45
+ "3. The field isn't supported for this endpoint. " +
46
+ "Try removing optional fields and adding them back one at a time to identify the problematic field.",
47
+ relatedTool,
48
+ });
49
+ }
50
+ // Parse "selected choice is invalid" errors - enum value mismatch
51
+ if (fullMessage.includes("selected choice is invalid") || fullMessage.includes("The selected choice is invalid")) {
52
+ return new OpenLoyaltyError({
53
+ code: "VALIDATION_ERROR",
54
+ message: fullMessage,
55
+ hint: "An enum value is invalid. Common fixes: " +
56
+ "1. For interval types, use 'calendarDays' (not 'days'), 'calendarWeeks', 'calendarMonths', 'calendarYears'. " +
57
+ "2. For period types, use 'day' (not 'days'), 'week', 'month', 'year'. " +
58
+ "3. For trigger types, check the tool description for valid options. " +
59
+ "4. Omit interval entirely for lifetime/forever limits.",
60
+ relatedTool,
61
+ });
62
+ }
34
63
  // If API returns "Validation failed" with no details, it's likely an MCP implementation bug
35
64
  const hasNoDetails = !errorDetails && data?.message === "Validation failed";
36
65
  const hint = hasNoDetails
@@ -38,9 +67,7 @@ export function formatApiError(error, relatedTool) {
38
67
  : "Check the input parameters match the expected format";
39
68
  return new OpenLoyaltyError({
40
69
  code: "VALIDATION_ERROR",
41
- message: data?.message
42
- ? `${String(data.message)}${errorDetails ? ` (${errorDetails})` : ''}`
43
- : "Invalid request data",
70
+ message: fullMessage,
44
71
  hint,
45
72
  relatedTool,
46
73
  });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * App Login Streak Workflow
3
+ *
4
+ * Creates a campaign to reward fans for daily app logins,
5
+ * with achievements for consecutive login streaks.
6
+ */
7
+ export interface AppLoginStreakConfig {
8
+ /** Coins awarded per daily login */
9
+ coinsPerLogin: number;
10
+ /** Whether to create streak bonus achievement */
11
+ createStreakBonus: boolean;
12
+ /** Number of consecutive days for streak achievement */
13
+ streakDays: number;
14
+ /** Bonus coins for completing the streak */
15
+ streakBonus: number;
16
+ /** Badge name for streak achievement (optional) */
17
+ badgeName?: string;
18
+ /** Season start date (ISO format) */
19
+ seasonStart: string;
20
+ /** Season end date (ISO format) */
21
+ seasonEnd: string;
22
+ }
23
+ export interface AppLoginStreakResult {
24
+ success: boolean;
25
+ loginCampaignId?: string;
26
+ streakAchievementId?: string;
27
+ streakBonusCampaignId?: string;
28
+ errors: string[];
29
+ summary: string;
30
+ }
31
+ /**
32
+ * Execute the app login streak workflow
33
+ */
34
+ export declare function executeAppLoginStreakWorkflow(config?: Partial<AppLoginStreakConfig>): Promise<AppLoginStreakResult>;
35
+ export declare const appLoginStreakWorkflow: {
36
+ id: string;
37
+ name: string;
38
+ execute: typeof executeAppLoginStreakWorkflow;
39
+ };
@@ -0,0 +1,298 @@
1
+ /**
2
+ * App Login Streak Workflow
3
+ *
4
+ * Creates a campaign to reward fans for daily app logins,
5
+ * with achievements for consecutive login streaks.
6
+ */
7
+ import { campaignCreate, campaignList } from "../tools/campaign/handlers.js";
8
+ import { achievementCreate, achievementList } from "../tools/achievement.js";
9
+ import { badgeList } from "../tools/badge.js";
10
+ import { formatOLDate, DEFAULTS } from "../prompts/fan-engagement-setup.js";
11
+ // ============================================================================
12
+ // Workflow Implementation
13
+ // ============================================================================
14
+ /**
15
+ * Execute the app login streak workflow
16
+ */
17
+ export async function executeAppLoginStreakWorkflow(config = {}) {
18
+ const result = {
19
+ success: false,
20
+ errors: [],
21
+ summary: "",
22
+ };
23
+ // Merge with defaults
24
+ const cfg = {
25
+ coinsPerLogin: config.coinsPerLogin ?? DEFAULTS.appLoginStreak.coinsPerLogin,
26
+ createStreakBonus: config.createStreakBonus ?? true,
27
+ streakDays: config.streakDays ?? DEFAULTS.appLoginStreak.streakDays,
28
+ streakBonus: config.streakBonus ?? DEFAULTS.appLoginStreak.streakBonus,
29
+ badgeName: config.badgeName ?? "Dedicated Fan",
30
+ seasonStart: config.seasonStart ?? DEFAULTS.seasonDates.start,
31
+ seasonEnd: config.seasonEnd ?? DEFAULTS.seasonDates.end,
32
+ };
33
+ try {
34
+ // Step 1: Create daily login campaign (limited to 1 per day)
35
+ const loginCampaignResult = await createLoginCampaign(cfg);
36
+ if (loginCampaignResult.error) {
37
+ result.errors.push(loginCampaignResult.error);
38
+ }
39
+ else {
40
+ result.loginCampaignId = loginCampaignResult.campaignId;
41
+ }
42
+ // Step 2: Create streak achievement and bonus campaign if enabled
43
+ if (cfg.createStreakBonus) {
44
+ const badges = await getAvailableBadges();
45
+ const achievementResult = await createStreakAchievement(cfg, badges);
46
+ if (achievementResult.error) {
47
+ result.errors.push(achievementResult.error);
48
+ }
49
+ else {
50
+ result.streakAchievementId = achievementResult.achievementId;
51
+ // Create bonus campaign for streak achievement
52
+ if (achievementResult.achievementId) {
53
+ const bonusCampaignResult = await createStreakBonusCampaign(achievementResult.achievementId, cfg);
54
+ if (bonusCampaignResult.error) {
55
+ result.errors.push(bonusCampaignResult.error);
56
+ }
57
+ else {
58
+ result.streakBonusCampaignId = bonusCampaignResult.campaignId;
59
+ }
60
+ }
61
+ }
62
+ }
63
+ // Step 3: Verify setup
64
+ const verification = await verifySetup(result);
65
+ if (verification.warnings.length > 0) {
66
+ result.errors.push(...verification.warnings);
67
+ }
68
+ // Determine success
69
+ result.success = result.loginCampaignId !== undefined && result.errors.length === 0;
70
+ // Build summary
71
+ result.summary = buildSummary(cfg, result);
72
+ }
73
+ catch (error) {
74
+ result.errors.push(`Workflow error: ${error instanceof Error ? error.message : String(error)}`);
75
+ }
76
+ return result;
77
+ }
78
+ // ============================================================================
79
+ // Helper Functions
80
+ // ============================================================================
81
+ async function createLoginCampaign(cfg) {
82
+ try {
83
+ const response = await campaignCreate({
84
+ type: "direct",
85
+ trigger: "custom_event",
86
+ event: "app_login",
87
+ translations: {
88
+ en: {
89
+ name: "Daily App Login Reward",
90
+ description: `Earn ${cfg.coinsPerLogin} coins for logging into the app daily`,
91
+ },
92
+ },
93
+ activity: {
94
+ startsAt: formatOLDate(cfg.seasonStart),
95
+ endsAt: formatOLDate(cfg.seasonEnd),
96
+ },
97
+ rules: [
98
+ {
99
+ name: "Award coins for daily login",
100
+ effects: [
101
+ {
102
+ effect: "give_points",
103
+ pointsRule: { fixedValue: cfg.coinsPerLogin },
104
+ },
105
+ ],
106
+ },
107
+ ],
108
+ limits: {
109
+ // Strictly one login reward per day
110
+ executionsPerMember: {
111
+ value: 1,
112
+ interval: { type: "days", value: 1 },
113
+ },
114
+ },
115
+ active: true,
116
+ });
117
+ return { campaignId: response.campaignId };
118
+ }
119
+ catch (error) {
120
+ return {
121
+ error: `Failed to create login campaign: ${error instanceof Error ? error.message : String(error)}`,
122
+ };
123
+ }
124
+ }
125
+ async function getAvailableBadges() {
126
+ try {
127
+ const response = await badgeList({});
128
+ const badgeMap = new Map();
129
+ for (const badge of response.badges) {
130
+ if (badge.name && badge.badgeTypeId) {
131
+ badgeMap.set(badge.name.toLowerCase(), badge.badgeTypeId);
132
+ }
133
+ }
134
+ return badgeMap;
135
+ }
136
+ catch {
137
+ return new Map();
138
+ }
139
+ }
140
+ async function createStreakAchievement(cfg, badges) {
141
+ try {
142
+ const badgeTypeId = cfg.badgeName
143
+ ? badges.get(cfg.badgeName.toLowerCase())
144
+ : undefined;
145
+ /**
146
+ * Streak achievements in Open Loyalty use:
147
+ * - periodGoal: 1 (one login per period)
148
+ * - period: { consecutive: X, value: 1 } where X is streak days
149
+ *
150
+ * This tracks consecutive periods (days) where the goal is met.
151
+ */
152
+ const achievementPayload = {
153
+ translations: {
154
+ en: {
155
+ name: `${cfg.streakDays}-Day Login Streak`,
156
+ description: `Log in ${cfg.streakDays} consecutive days to complete this achievement`,
157
+ },
158
+ },
159
+ active: true,
160
+ activity: {
161
+ startsAt: formatOLDate(cfg.seasonStart),
162
+ endsAt: formatOLDate(cfg.seasonEnd),
163
+ },
164
+ rules: [
165
+ {
166
+ trigger: "custom_event",
167
+ event: "app_login",
168
+ completeRule: {
169
+ periodGoal: 1,
170
+ period: {
171
+ consecutive: cfg.streakDays,
172
+ value: 1, // 1 day period
173
+ },
174
+ },
175
+ },
176
+ ],
177
+ };
178
+ if (badgeTypeId) {
179
+ achievementPayload.badgeTypeId = badgeTypeId;
180
+ }
181
+ const response = await achievementCreate(achievementPayload);
182
+ return { achievementId: response.achievementId };
183
+ }
184
+ catch (error) {
185
+ return {
186
+ error: `Failed to create streak achievement: ${error instanceof Error ? error.message : String(error)}`,
187
+ };
188
+ }
189
+ }
190
+ async function createStreakBonusCampaign(achievementId, cfg) {
191
+ try {
192
+ const response = await campaignCreate({
193
+ type: "direct",
194
+ trigger: "achievement",
195
+ translations: {
196
+ en: {
197
+ name: `${cfg.streakDays}-Day Login Streak Bonus`,
198
+ description: `Bonus ${cfg.streakBonus} coins for maintaining a ${cfg.streakDays}-day login streak`,
199
+ },
200
+ },
201
+ activity: {
202
+ startsAt: formatOLDate(cfg.seasonStart),
203
+ endsAt: formatOLDate(cfg.seasonEnd),
204
+ },
205
+ rules: [
206
+ {
207
+ name: `Bonus for ${cfg.streakDays}-day streak`,
208
+ effects: [
209
+ {
210
+ effect: "give_points",
211
+ pointsRule: { fixedValue: cfg.streakBonus },
212
+ },
213
+ ],
214
+ conditions: [
215
+ {
216
+ operator: "is_equal",
217
+ attribute: "achievement.achievementId",
218
+ data: { value: achievementId },
219
+ },
220
+ ],
221
+ },
222
+ ],
223
+ active: true,
224
+ });
225
+ return { campaignId: response.campaignId };
226
+ }
227
+ catch (error) {
228
+ return {
229
+ error: `Failed to create streak bonus campaign: ${error instanceof Error ? error.message : String(error)}`,
230
+ };
231
+ }
232
+ }
233
+ async function verifySetup(result) {
234
+ const warnings = [];
235
+ try {
236
+ if (result.loginCampaignId) {
237
+ const campaigns = await campaignList({ active: true });
238
+ const found = campaigns.campaigns.some((c) => c.campaignId === result.loginCampaignId);
239
+ if (!found) {
240
+ warnings.push("Login campaign created but not found in active campaigns list");
241
+ }
242
+ }
243
+ if (result.streakAchievementId) {
244
+ const achievements = await achievementList({ active: true });
245
+ const found = achievements.achievements.some((a) => a.achievementId === result.streakAchievementId);
246
+ if (!found) {
247
+ warnings.push("Streak achievement created but not found in active achievements list");
248
+ }
249
+ }
250
+ }
251
+ catch (error) {
252
+ warnings.push(`Verification error: ${error instanceof Error ? error.message : String(error)}`);
253
+ }
254
+ return { warnings };
255
+ }
256
+ function buildSummary(cfg, result) {
257
+ const lines = [];
258
+ if (result.loginCampaignId) {
259
+ lines.push(`Daily login campaign created!`);
260
+ lines.push(`\nDaily reward: ${cfg.coinsPerLogin} coins`);
261
+ lines.push(`Limit: 1 reward per day (prevents multiple login scans)`);
262
+ }
263
+ if (result.streakAchievementId) {
264
+ lines.push(`\nStreak Achievement: ${cfg.streakDays} consecutive days`);
265
+ if (cfg.badgeName) {
266
+ lines.push(`Badge: ${cfg.badgeName}`);
267
+ }
268
+ if (result.streakBonusCampaignId) {
269
+ lines.push(`Streak bonus: ${cfg.streakBonus} coins`);
270
+ }
271
+ }
272
+ lines.push(`\nSeason: ${cfg.seasonStart} to ${cfg.seasonEnd}`);
273
+ lines.push(`\nCustom event to trigger reward:`);
274
+ lines.push(` Event: app_login`);
275
+ lines.push(`\nExample event payload:`);
276
+ lines.push(`{`);
277
+ lines.push(` "event": "app_login"`);
278
+ lines.push(`}`);
279
+ lines.push(`\nStreak mechanics:`);
280
+ lines.push(`- Fan must log in at least once each day`);
281
+ lines.push(`- Missing a day resets the streak counter`);
282
+ lines.push(`- Achievement completes when ${cfg.streakDays} consecutive days are reached`);
283
+ if (result.errors.length > 0) {
284
+ lines.push(`\nWarnings/Errors:`);
285
+ for (const error of result.errors) {
286
+ lines.push(`- ${error}`);
287
+ }
288
+ }
289
+ return lines.join("\n");
290
+ }
291
+ // ============================================================================
292
+ // Exports
293
+ // ============================================================================
294
+ export const appLoginStreakWorkflow = {
295
+ id: "app-login-streak",
296
+ name: "App Login Streak Campaign",
297
+ execute: executeAppLoginStreakWorkflow,
298
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Early Arrival Workflow
3
+ *
4
+ * Creates a campaign to reward fans who arrive early to matches.
5
+ * Uses a custom event with minutes_before attribute for conditional rewards.
6
+ */
7
+ export interface EarlyArrivalConfig {
8
+ /** How early (in minutes) counts as "early arrival" */
9
+ minutesBefore: number;
10
+ /** Coins awarded for early arrival */
11
+ coinsPerArrival: number;
12
+ /** Maximum early arrival bonuses per match (typically 1) */
13
+ limitPerMatch: number;
14
+ /** Season start date (ISO format) */
15
+ seasonStart: string;
16
+ /** Season end date (ISO format) */
17
+ seasonEnd: string;
18
+ }
19
+ export interface EarlyArrivalResult {
20
+ success: boolean;
21
+ campaignId?: string;
22
+ errors: string[];
23
+ summary: string;
24
+ }
25
+ /**
26
+ * Execute the early arrival workflow
27
+ */
28
+ export declare function executeEarlyArrivalWorkflow(config?: Partial<EarlyArrivalConfig>): Promise<EarlyArrivalResult>;
29
+ export declare const earlyArrivalWorkflow: {
30
+ id: string;
31
+ name: string;
32
+ execute: typeof executeEarlyArrivalWorkflow;
33
+ };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Early Arrival Workflow
3
+ *
4
+ * Creates a campaign to reward fans who arrive early to matches.
5
+ * Uses a custom event with minutes_before attribute for conditional rewards.
6
+ */
7
+ import { campaignCreate } from "../tools/campaign/handlers.js";
8
+ import { formatOLDate, DEFAULTS } from "../prompts/fan-engagement-setup.js";
9
+ // ============================================================================
10
+ // Workflow Implementation
11
+ // ============================================================================
12
+ /**
13
+ * Execute the early arrival workflow
14
+ */
15
+ export async function executeEarlyArrivalWorkflow(config = {}) {
16
+ const result = {
17
+ success: false,
18
+ errors: [],
19
+ summary: "",
20
+ };
21
+ // Merge with defaults
22
+ const cfg = {
23
+ minutesBefore: config.minutesBefore ?? DEFAULTS.earlyArrival.minutesBefore,
24
+ coinsPerArrival: config.coinsPerArrival ?? DEFAULTS.earlyArrival.coinsPerArrival,
25
+ limitPerMatch: config.limitPerMatch ?? DEFAULTS.earlyArrival.limitPerMatch,
26
+ seasonStart: config.seasonStart ?? DEFAULTS.seasonDates.start,
27
+ seasonEnd: config.seasonEnd ?? DEFAULTS.seasonDates.end,
28
+ };
29
+ try {
30
+ // Create the early arrival campaign with condition
31
+ const campaignResult = await createEarlyArrivalCampaign(cfg);
32
+ if (campaignResult.error) {
33
+ result.errors.push(campaignResult.error);
34
+ }
35
+ else {
36
+ result.campaignId = campaignResult.campaignId;
37
+ result.success = true;
38
+ }
39
+ // Build summary
40
+ result.summary = buildSummary(cfg, result);
41
+ }
42
+ catch (error) {
43
+ result.errors.push(`Workflow error: ${error instanceof Error ? error.message : String(error)}`);
44
+ }
45
+ return result;
46
+ }
47
+ // ============================================================================
48
+ // Helper Functions
49
+ // ============================================================================
50
+ async function createEarlyArrivalCampaign(cfg) {
51
+ try {
52
+ /**
53
+ * The early_arrival custom event expects:
54
+ * {
55
+ * "event": "early_arrival",
56
+ * "attributes": {
57
+ * "minutes_before": 75 // how many minutes before kickoff they arrived
58
+ * }
59
+ * }
60
+ *
61
+ * The campaign condition checks if minutes_before >= threshold
62
+ */
63
+ const response = await campaignCreate({
64
+ type: "direct",
65
+ trigger: "custom_event",
66
+ event: "early_arrival",
67
+ translations: {
68
+ en: {
69
+ name: "Early Arrival Bonus",
70
+ description: `Earn ${cfg.coinsPerArrival} bonus coins for arriving at least ${cfg.minutesBefore} minutes before kickoff`,
71
+ },
72
+ },
73
+ activity: {
74
+ startsAt: formatOLDate(cfg.seasonStart),
75
+ endsAt: formatOLDate(cfg.seasonEnd),
76
+ },
77
+ rules: [
78
+ {
79
+ name: "Award coins for early arrival",
80
+ effects: [
81
+ {
82
+ effect: "give_points",
83
+ pointsRule: { fixedValue: cfg.coinsPerArrival },
84
+ },
85
+ ],
86
+ conditions: [
87
+ {
88
+ operator: "gte",
89
+ attribute: "event.minutes_before",
90
+ data: { value: cfg.minutesBefore },
91
+ },
92
+ ],
93
+ },
94
+ ],
95
+ limits: {
96
+ executionsPerMember: {
97
+ value: cfg.limitPerMatch,
98
+ interval: { type: "days", value: 1 },
99
+ },
100
+ },
101
+ active: true,
102
+ });
103
+ return { campaignId: response.campaignId };
104
+ }
105
+ catch (error) {
106
+ return {
107
+ error: `Failed to create early arrival campaign: ${error instanceof Error ? error.message : String(error)}`,
108
+ };
109
+ }
110
+ }
111
+ function buildSummary(cfg, result) {
112
+ const lines = [];
113
+ if (result.success && result.campaignId) {
114
+ lines.push(`Early arrival campaign created successfully!`);
115
+ lines.push(`\nReward: ${cfg.coinsPerArrival} coins`);
116
+ lines.push(`Requirement: Arrive at least ${cfg.minutesBefore} minutes before kickoff`);
117
+ lines.push(`Limit: ${cfg.limitPerMatch} bonus per match day`);
118
+ lines.push(`\nSeason: ${cfg.seasonStart} to ${cfg.seasonEnd}`);
119
+ lines.push(`\nCustom event to trigger reward:`);
120
+ lines.push(` Event: early_arrival`);
121
+ lines.push(` Required attribute: minutes_before (number)`);
122
+ lines.push(`\nExample event payload:`);
123
+ lines.push(`{`);
124
+ lines.push(` "event": "early_arrival",`);
125
+ lines.push(` "attributes": {`);
126
+ lines.push(` "minutes_before": 75`);
127
+ lines.push(` }`);
128
+ lines.push(`}`);
129
+ }
130
+ else {
131
+ lines.push(`Early arrival campaign setup failed.`);
132
+ }
133
+ if (result.errors.length > 0) {
134
+ lines.push(`\nErrors:`);
135
+ for (const error of result.errors) {
136
+ lines.push(`- ${error}`);
137
+ }
138
+ }
139
+ return lines.join("\n");
140
+ }
141
+ // ============================================================================
142
+ // Exports
143
+ // ============================================================================
144
+ export const earlyArrivalWorkflow = {
145
+ id: "early-arrival",
146
+ name: "Early Arrival Campaign",
147
+ execute: executeEarlyArrivalWorkflow,
148
+ };