@tryvitris/website-admin-contracts 1.5.0 → 1.7.0

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.
package/README.md CHANGED
@@ -3,3 +3,7 @@
3
3
  Validated installation manifests, roles, permissions and module contracts for `@tryvitris/website-admin-harness`. Node.js 24+. MIT licensed.
4
4
 
5
5
  The maintenance contract exports the stable admin package family and typed installed/locked/published-version reports and preparation plans. Its policy is prepare-and-test under existing publication permissions. It does not execute updates or create schedules; use the runtime maintenance CLI and its agent guide.
6
+
7
+ The `./email-campaigns` entry exports validated contacts, evidence and preferences, bounded audience rules, structured templates, communication purposes, guided workflow steps, website events, account cost calculations, and workspace types. Explicit email permissions remain excluded from existing non-owner role defaults.
8
+
9
+ Google Search Console contracts include performance requests, report provenance/pagination, connection status, supported types/dimensions and `search-console:manage` for connection and sitemap changes.
@@ -0,0 +1,209 @@
1
+ /** Cloudflare-only customer communications. Newsletter content is draft-only. */
2
+ export declare const emailCampaignPermissions: readonly ["email-campaigns:read", "email-reports:read", "email-contacts:read", "email-contacts:write", "email-contacts:export", "email-content:write", "email-campaigns:send", "email-automations:manage", "email-settings:manage"];
3
+ export type EmailPermission = typeof emailCampaignPermissions[number];
4
+ export declare const emailPurposes: readonly ["customer-report", "service-update", "enquiry-response", "newsletter-draft"];
5
+ export type EmailPurpose = typeof emailPurposes[number];
6
+ export type EmailTopic = 'reports' | 'service' | 'newsletter';
7
+ export interface EmailModuleConfig {
8
+ feedbackQueue: string;
9
+ accountId?: string;
10
+ zoneId?: string;
11
+ sendingDomain?: string;
12
+ eventSubscriptionId?: string;
13
+ dailyLimit?: number;
14
+ }
15
+ export interface EmailSettings {
16
+ enabled: boolean;
17
+ mode: 'capture' | 'send';
18
+ senderName: string;
19
+ logoUrl: string;
20
+ replyTo: string;
21
+ postalAddress: string;
22
+ accent: string;
23
+ timezone: string;
24
+ perMinute: number;
25
+ dailyLimit: number;
26
+ monthlyLimit: number;
27
+ recipientCap: number;
28
+ spacingHours: number;
29
+ otherAccountEmails: number;
30
+ reservedDaily: number;
31
+ clickTracking: boolean;
32
+ }
33
+ export declare const defaultEmailSettings: () => EmailSettings;
34
+ export interface EmailContact {
35
+ id: string;
36
+ revision: number;
37
+ email: string;
38
+ name: string;
39
+ company: string;
40
+ stage: string;
41
+ tags: string[];
42
+ fields: Record<string, string>;
43
+ source: string;
44
+ interactionRef: string;
45
+ serviceRef: string;
46
+ topics: Partial<Record<EmailTopic, boolean>>;
47
+ evidence: string;
48
+ deleted?: boolean;
49
+ }
50
+ export interface EmailRule {
51
+ field: string;
52
+ op: 'eq' | 'neq' | 'contains' | 'exists' | 'not_exists';
53
+ value: string;
54
+ }
55
+ export interface EmailSegment {
56
+ id: string;
57
+ revision: number;
58
+ name: string;
59
+ match: 'all' | 'any';
60
+ rules: EmailRule[];
61
+ }
62
+ export interface EmailBlock {
63
+ type: 'heading' | 'paragraph' | 'button' | 'image' | 'divider';
64
+ text: string;
65
+ url?: string;
66
+ alt?: string;
67
+ }
68
+ export interface EmailTemplate {
69
+ id: string;
70
+ revision: number;
71
+ name: string;
72
+ layout: 'letter' | 'newsletter' | 'report' | 'update';
73
+ subject: string;
74
+ preheader: string;
75
+ blocks: EmailBlock[];
76
+ }
77
+ export interface EmailCampaign {
78
+ id: string;
79
+ revision: number;
80
+ name: string;
81
+ purpose: EmailPurpose;
82
+ templateId: string;
83
+ segmentId: string;
84
+ excludeIds: string[];
85
+ scheduledAt: string;
86
+ status: 'draft' | 'preparing' | 'scheduled' | 'running' | 'paused' | 'cancelled' | 'completed';
87
+ snapshot?: EmailTemplate;
88
+ audienceCutoff?: number;
89
+ cursor?: string;
90
+ approvedBy?: string;
91
+ approvalRevision?: number;
92
+ recurrenceDays?: number;
93
+ nextIssueAt?: number;
94
+ sourceArticle?: string;
95
+ }
96
+ export type EmailStep = {
97
+ type: 'wait';
98
+ minutes: number;
99
+ } | {
100
+ type: 'send';
101
+ templateId: string;
102
+ } | {
103
+ type: 'condition';
104
+ rule: EmailRule;
105
+ skip: number;
106
+ } | {
107
+ type: 'tag';
108
+ value: string;
109
+ } | {
110
+ type: 'stage';
111
+ value: string;
112
+ } | {
113
+ type: 'task';
114
+ title: string;
115
+ } | {
116
+ type: 'exit';
117
+ };
118
+ export interface EmailAutomation {
119
+ id: string;
120
+ revision: number;
121
+ name: string;
122
+ trigger: 'lead.created' | 'service.updated' | 'report.ready' | 'manual' | 'subscription.confirmed';
123
+ purpose: EmailPurpose;
124
+ status: 'draft' | 'active' | 'paused';
125
+ steps: EmailStep[];
126
+ version?: number;
127
+ publisherId?: string;
128
+ hasDraft?: boolean;
129
+ }
130
+ export interface EmailWebsiteEvent {
131
+ version: 1;
132
+ mode: 'production' | 'test';
133
+ id: string;
134
+ installationId: string;
135
+ siteId: string;
136
+ type: 'lead.created' | 'service.updated' | 'report.ready' | 'customer.converted' | 'enquiry.resolved' | 'subscription.confirmed';
137
+ occurredAt: string;
138
+ /** Optional campaign attribution, verified against an actual live message to this contact. */
139
+ campaignId?: string;
140
+ contact: {
141
+ email: string;
142
+ name: string;
143
+ company?: string;
144
+ interactionRef?: string;
145
+ serviceRef?: string;
146
+ };
147
+ report?: {
148
+ title: string;
149
+ period: string;
150
+ summary: string;
151
+ url?: string;
152
+ };
153
+ }
154
+ export interface EmailRecord<T> {
155
+ id: string;
156
+ revision: number;
157
+ data: T;
158
+ createdAt: number;
159
+ updatedAt: number;
160
+ }
161
+ export interface EmailJob {
162
+ id: string;
163
+ campaignId: string;
164
+ contactId: string;
165
+ state: string;
166
+ reason: string | null;
167
+ mode: 'capture' | 'send';
168
+ dueAt: number;
169
+ createdAt: number;
170
+ providerMessageId: string | null;
171
+ }
172
+ export interface EmailWorkspace {
173
+ settings: EmailRecord<EmailSettings>;
174
+ readiness: {
175
+ schemaReady: boolean;
176
+ keysReady: boolean;
177
+ senderReady: boolean;
178
+ liveReady: boolean;
179
+ feedbackReady: boolean;
180
+ websiteReady: boolean;
181
+ deliveryPaused: boolean;
182
+ deliveryReason: string;
183
+ from: string;
184
+ issues: string[];
185
+ };
186
+ permissions: EmailPermission[];
187
+ counts: Record<string, number>;
188
+ monthly: {
189
+ counted: number;
190
+ estimatedOverage: number;
191
+ included: number;
192
+ };
193
+ templates: EmailTemplate[];
194
+ }
195
+ export declare class EmailInputError extends Error {
196
+ constructor(message: string);
197
+ }
198
+ export declare const validEmail: (v: unknown) => v is string;
199
+ export declare const emailId: (v: unknown) => v is string;
200
+ export declare function safeEmailUrl(v: unknown): string;
201
+ export declare function parseEmailRule(v: unknown): EmailRule;
202
+ export declare function parseEmailContact(v: unknown): EmailContact;
203
+ export declare function parseEmailSegment(v: unknown): EmailSegment;
204
+ export declare function parseEmailTemplate(v: unknown): EmailTemplate;
205
+ export declare function parseEmailCampaign(v: unknown): EmailCampaign;
206
+ export declare function parseEmailAutomation(v: unknown): EmailAutomation;
207
+ export declare function parseEmailSettings(v: unknown): EmailSettings;
208
+ export declare function emailOverage(count: number): number;
209
+ export declare function starterEmailTemplates(): EmailTemplate[];
@@ -0,0 +1,166 @@
1
+ // packages/contracts/src/email-campaigns.ts
2
+ var emailCampaignPermissions = ["email-campaigns:read", "email-reports:read", "email-contacts:read", "email-contacts:write", "email-contacts:export", "email-content:write", "email-campaigns:send", "email-automations:manage", "email-settings:manage"];
3
+ var emailPurposes = ["customer-report", "service-update", "enquiry-response", "newsletter-draft"];
4
+ var defaultEmailSettings = () => ({ enabled: false, mode: "capture", senderName: "", logoUrl: "", replyTo: "", postalAddress: "", accent: "#245c50", timezone: "America/New_York", perMinute: 50, dailyLimit: 500, monthlyLimit: 3e3, recipientCap: 5e3, spacingHours: 24, otherAccountEmails: 0, reservedDaily: 50, clickTracking: false });
5
+ var EmailInputError = class extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "EmailInputError";
9
+ }
10
+ };
11
+ var object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
12
+ var validEmail = (v) => typeof v === "string" && v.length <= 254 && /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(v) && !/[\u0000-\u001f\u007f]/.test(v);
13
+ var emailId = (v) => typeof v === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(v);
14
+ function string(v, max, empty = true) {
15
+ if (typeof v !== "string" || v.length > max || !empty && !v.trim() || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(v))
16
+ throw new EmailInputError("Enter a valid text value.");
17
+ return v.trim();
18
+ }
19
+ function integer(v, min, max) {
20
+ if (!Number.isInteger(v) || Number(v) < min || Number(v) > max)
21
+ throw new EmailInputError(`Use a whole number from ${min} to ${max}.`);
22
+ return Number(v);
23
+ }
24
+ function choice(v, values) {
25
+ if (!values.includes(v))
26
+ throw new EmailInputError("Choose a supported option.");
27
+ return v;
28
+ }
29
+ function common(v) {
30
+ return { id: v.id === void 0 ? "" : v.id === "" ? "" : emailId(v.id) ? v.id : (() => {
31
+ throw new EmailInputError("Invalid record ID.");
32
+ })(), revision: integer(v.revision ?? 0, 0, 1e8) };
33
+ }
34
+ function safeEmailUrl(v) {
35
+ const value = string(v, 2e3, false);
36
+ try {
37
+ const u = new URL(value);
38
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname)) || u.username || u.password)
39
+ throw Error();
40
+ return u.href;
41
+ } catch {
42
+ throw new EmailInputError("Use an absolute HTTPS link.");
43
+ }
44
+ }
45
+ function parseEmailRule(v) {
46
+ if (!object(v) || typeof v.field !== "string" || !/^((stage|tag|source|company|service|interaction|topic|event)|custom\.[a-zA-Z][a-zA-Z0-9_-]{0,30})$/.test(v.field))
47
+ throw new EmailInputError("Choose a supported segment field.");
48
+ return { field: v.field, op: choice(v.op, ["eq", "neq", "contains", "exists", "not_exists"]), value: string(v.value ?? "", 300) };
49
+ }
50
+ function parseEmailContact(v) {
51
+ if (!object(v) || !validEmail(v.email))
52
+ throw new EmailInputError("Enter a valid contact email.");
53
+ if (!Array.isArray(v.tags) || v.tags.length > 30 || !object(v.fields) || Object.keys(v.fields).length > 20 || !object(v.topics))
54
+ throw new EmailInputError("Use at most 30 tags and 20 custom fields.");
55
+ const fields = {};
56
+ for (const [key, value] of Object.entries(v.fields)) {
57
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,30}$/.test(key))
58
+ throw new EmailInputError("Invalid custom field name.");
59
+ fields[key] = string(value, 500);
60
+ }
61
+ const topics = {};
62
+ for (const [key, value] of Object.entries(v.topics)) {
63
+ if (!["reports", "service", "newsletter"].includes(key) || typeof value !== "boolean")
64
+ throw new EmailInputError("Invalid topic preference.");
65
+ topics[key] = value;
66
+ }
67
+ return { ...common(v), email: v.email.trim(), name: string(v.name ?? "", 150), company: string(v.company ?? "", 150), stage: string(v.stage ?? "lead", 50, false), tags: [...new Set(v.tags.map((t) => string(t, 60, false)))], fields, source: string(v.source ?? "manual", 100), interactionRef: string(v.interactionRef ?? "", 200), serviceRef: string(v.serviceRef ?? "", 200), topics, evidence: string(v.evidence ?? "", 1e3) };
68
+ }
69
+ function parseEmailSegment(v) {
70
+ if (!object(v) || !Array.isArray(v.rules) || v.rules.length > 16)
71
+ throw new EmailInputError("Use at most 16 segment rules.");
72
+ return { ...common(v), name: string(v.name, 150, false), match: choice(v.match, ["all", "any"]), rules: v.rules.map(parseEmailRule) };
73
+ }
74
+ function parseEmailTemplate(v) {
75
+ if (!object(v) || !Array.isArray(v.blocks) || v.blocks.length < 1 || v.blocks.length > 40)
76
+ throw new EmailInputError("Use 1 to 40 content sections.");
77
+ const subject = string(v.subject, 250, false);
78
+ if (/[\r\n]/.test(subject))
79
+ throw new EmailInputError("Subject must be one line.");
80
+ return { ...common(v), name: string(v.name, 150, false), layout: choice(v.layout, ["letter", "newsletter", "report", "update"]), subject, preheader: string(v.preheader ?? "", 250), blocks: v.blocks.map((b) => {
81
+ if (!object(b))
82
+ throw new EmailInputError("Invalid content section.");
83
+ const type = choice(b.type, ["heading", "paragraph", "button", "image", "divider"]);
84
+ const url = b.url ? safeEmailUrl(b.url) : void 0;
85
+ if ((type === "button" || type === "image") && !url)
86
+ throw new EmailInputError("Buttons and images need an HTTPS link.");
87
+ return { type, text: string(b.text ?? "", 5e3), ...url ? { url } : {}, ...type === "image" ? { alt: string(b.alt ?? "", 300, false) } : {} };
88
+ }) };
89
+ }
90
+ function parseEmailCampaign(v) {
91
+ if (!object(v) || !emailId(v.templateId) || v.segmentId !== "" && !emailId(v.segmentId) || !Array.isArray(v.excludeIds) || v.excludeIds.length > 500 || v.excludeIds.some((x) => !emailId(x)))
92
+ throw new EmailInputError("Choose a template, audience, and valid exclusions.");
93
+ let scheduledAt = string(v.scheduledAt ?? "", 40);
94
+ if (scheduledAt && (!/Z$/.test(scheduledAt) || !Number.isFinite(Date.parse(scheduledAt))))
95
+ throw new EmailInputError("Schedule with an absolute UTC date.");
96
+ return { ...common(v), name: string(v.name, 150, false), purpose: choice(v.purpose, emailPurposes), templateId: v.templateId, segmentId: v.segmentId, excludeIds: v.excludeIds, scheduledAt, status: "draft", recurrenceDays: integer(v.recurrenceDays ?? 0, 0, 365), ...v.sourceArticle ? { sourceArticle: safeEmailUrl(v.sourceArticle) } : {} };
97
+ }
98
+ function parseEmailAutomation(v) {
99
+ if (!object(v) || !Array.isArray(v.steps) || !v.steps.length || v.steps.length > 12)
100
+ throw new EmailInputError("Use 1 to 12 forward-only steps.");
101
+ const steps = v.steps.map((s, index) => {
102
+ if (!object(s))
103
+ throw new EmailInputError("Invalid automation step.");
104
+ switch (s.type) {
105
+ case "wait":
106
+ return { type: s.type, minutes: integer(s.minutes, 1, 43200) };
107
+ case "send":
108
+ if (!emailId(s.templateId))
109
+ throw new EmailInputError("Choose a send template.");
110
+ return { type: s.type, templateId: s.templateId };
111
+ case "condition":
112
+ return { type: s.type, rule: parseEmailRule(s.rule), skip: integer(s.skip, 1, 12 - index) };
113
+ case "tag":
114
+ case "stage":
115
+ return { type: s.type, value: string(s.value, 60, false) };
116
+ case "task":
117
+ return { type: s.type, title: string(s.title, 180, false) };
118
+ case "exit":
119
+ return { type: s.type };
120
+ default:
121
+ throw new EmailInputError("Unsupported automation step.");
122
+ }
123
+ });
124
+ return { ...common(v), name: string(v.name, 150, false), trigger: choice(v.trigger, ["lead.created", "service.updated", "report.ready", "manual", "subscription.confirmed"]), purpose: choice(v.purpose, emailPurposes), status: "draft", steps };
125
+ }
126
+ function parseEmailSettings(v) {
127
+ if (!object(v) || typeof v.enabled !== "boolean" || typeof v.clickTracking !== "boolean" || typeof v.accent !== "string" || !/^#[\da-f]{6}$/i.test(v.accent) || v.replyTo && !validEmail(v.replyTo))
128
+ throw new EmailInputError("Check email settings and reply address.");
129
+ if (typeof v.senderName === "string" && /[\r\n]/.test(v.senderName)) throw new EmailInputError("Use a single line for the sender name.");
130
+ const timezone = string(v.timezone, 100, false);
131
+ try {
132
+ new Intl.DateTimeFormat("en", { timeZone: timezone });
133
+ } catch {
134
+ throw new EmailInputError("Choose a valid timezone.");
135
+ }
136
+ return { enabled: v.enabled, mode: choice(v.mode, ["capture", "send"]), senderName: string(v.senderName, 150), logoUrl: v.logoUrl ? safeEmailUrl(v.logoUrl) : "", replyTo: string(v.replyTo ?? "", 254), postalAddress: string(v.postalAddress ?? "", 500), accent: v.accent, timezone, perMinute: integer(v.perMinute, 1, 100), dailyLimit: integer(v.dailyLimit, 1, 1e5), monthlyLimit: integer(v.monthlyLimit, 1, 1e6), recipientCap: integer(v.recipientCap, 1, 1e4), spacingHours: integer(v.spacingHours, 0, 720), otherAccountEmails: integer(v.otherAccountEmails, 0, 1e7), reservedDaily: integer(v.reservedDaily, 0, 1e5), clickTracking: v.clickTracking };
137
+ }
138
+ function emailOverage(count) {
139
+ return Math.round(Math.max(0, count - 3e3) * 0.035) / 100;
140
+ }
141
+ function starterEmailTemplates() {
142
+ return [
143
+ { id: "starter-report", revision: 0, name: "Customer report", layout: "report", subject: "Your {{report.period|latest}} report", preheader: "An update on your agreed service.", blocks: [{ type: "heading", text: "{{report.title|Your service report}}" }, { type: "paragraph", text: "Hello {{firstName|there}},\n\n{{report.summary|Your requested report is ready.}}" }] },
144
+ { id: "starter-response", revision: 0, name: "Personal response", layout: "letter", subject: "We received your enquiry", preheader: "Your enquiry has reached our team.", blocks: [{ type: "paragraph", text: "Hello {{firstName|there}},\n\nThank you for contacting us. Our team has received your request and will respond with the information you asked for." }] },
145
+ { id: "starter-update", revision: 0, name: "Service update", layout: "update", subject: "An update on your service", preheader: "The latest status of your agreed service.", blocks: [{ type: "heading", text: "Your service update" }, { type: "paragraph", text: "Hello {{firstName|there}},\n\n{{report.summary|We have an update about your agreed service.}}" }] },
146
+ { id: "starter-newsletter", revision: 0, name: "Newsletter draft", layout: "newsletter", subject: "This month\u2019s notes", preheader: "Stories and updates.", blocks: [{ type: "heading", text: "This month\u2019s notes" }, { type: "paragraph", text: "Write your opening note here." }] }
147
+ ];
148
+ }
149
+ export {
150
+ EmailInputError,
151
+ defaultEmailSettings,
152
+ emailCampaignPermissions,
153
+ emailId,
154
+ emailOverage,
155
+ emailPurposes,
156
+ parseEmailAutomation,
157
+ parseEmailCampaign,
158
+ parseEmailContact,
159
+ parseEmailRule,
160
+ parseEmailSegment,
161
+ parseEmailSettings,
162
+ parseEmailTemplate,
163
+ safeEmailUrl,
164
+ starterEmailTemplates,
165
+ validEmail
166
+ };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export * from './email-campaigns.js';
2
+ export * from './search-console.js';
1
3
  export * from './billing.js';
2
4
  export * from './stripe-billing.js';
3
5
  export * from './client-portfolio.js';
@@ -8,7 +10,7 @@ export * from './pairing.js';
8
10
  export * from './signon.js';
9
11
  export declare const contractVersion = 1;
10
12
  export declare const coreSections: readonly ["overview", "leads", "analytics", "settings", "access", "activity"];
11
- export declare const permissions: readonly ["leads:read", "leads:write", "analytics:read", "settings:write", "access:read", "access:write", "activity:read", "tasks:read", "tasks:write", "appointments:read", "appointments:write", "availability:write", "scheduling:configure", "calendar-connections:manage", "clients:read", "client-changes:request", "client-releases:publish", "prospects:read", "prospects:write", "billing:read", "billing:customers:write", "billing:catalog:read", "billing:subscriptions:write", "billing:invoices:write", "billing:collect", "billing:connection:manage", "account-billing:read", "account-billing:manage"];
13
+ export declare const permissions: readonly ["leads:read", "leads:write", "analytics:read", "search-console:manage", "settings:write", "access:read", "access:write", "activity:read", "tasks:read", "tasks:write", "appointments:read", "appointments:write", "availability:write", "scheduling:configure", "calendar-connections:manage", "clients:read", "client-changes:request", "client-releases:publish", "prospects:read", "prospects:write", "billing:read", "billing:customers:write", "billing:catalog:read", "billing:subscriptions:write", "billing:invoices:write", "billing:collect", "billing:connection:manage", "account-billing:read", "account-billing:manage", "email-campaigns:read", "email-reports:read", "email-contacts:read", "email-contacts:write", "email-contacts:export", "email-content:write", "email-campaigns:send", "email-automations:manage", "email-settings:manage"];
12
14
  export type Permission = typeof permissions[number];
13
15
  export type Role = 'owner' | 'administrator' | 'editor' | 'analyst';
14
16
  export declare const roles: Record<Role, readonly Permission[]>;
@@ -41,7 +43,7 @@ export type WebsiteTheme = Partial<Record<typeof websiteColorKeys[number], strin
41
43
  export declare function isThemeColor(value: unknown): value is string;
42
44
  export declare function isWebsiteTheme(value: unknown): value is WebsiteTheme;
43
45
  export interface ModuleDefinition {
44
- ui?: 'stripe-billing' | 'stripe-account';
46
+ ui?: 'stripe-billing' | 'stripe-account' | 'email-campaigns';
45
47
  id: string;
46
48
  version: number;
47
49
  label: string;
@@ -74,6 +76,7 @@ export interface BootstrapResponse {
74
76
  emailLoginConfigured?: boolean;
75
77
  privateLocalSignIn?: boolean;
76
78
  webmasterEnabled?: boolean;
79
+ searchConsoleEnabled?: boolean;
77
80
  }
78
81
  export type DataSource = 'synthetic' | 'connected' | 'not-connected';
79
82
  export interface LeadRow {
package/dist/index.js CHANGED
@@ -2,6 +2,9 @@ import { schedulingPermissions } from "./scheduling.js";
2
2
  import { clientPortfolioPermissions } from "./client-portfolio.js";
3
3
  import { clientOnboardingPermissions } from "./client-onboarding.js";
4
4
  import { billingPermissions } from "./billing.js";
5
+ import { emailCampaignPermissions } from "./email-campaigns.js";
6
+ export * from "./email-campaigns.js";
7
+ export * from "./search-console.js";
5
8
  export * from "./billing.js";
6
9
  export * from "./stripe-billing.js";
7
10
  export * from "./client-portfolio.js";
@@ -12,10 +15,10 @@ export * from "./pairing.js";
12
15
  export * from "./signon.js";
13
16
  const contractVersion = 1;
14
17
  const coreSections = ["overview", "leads", "analytics", "settings", "access", "activity"];
15
- const permissions = ["leads:read", "leads:write", "analytics:read", "settings:write", "access:read", "access:write", "activity:read", "tasks:read", "tasks:write", ...schedulingPermissions, ...clientPortfolioPermissions, ...clientOnboardingPermissions, ...billingPermissions];
18
+ const permissions = ["leads:read", "leads:write", "analytics:read", "search-console:manage", "settings:write", "access:read", "access:write", "activity:read", "tasks:read", "tasks:write", ...schedulingPermissions, ...clientPortfolioPermissions, ...clientOnboardingPermissions, ...billingPermissions, ...emailCampaignPermissions];
16
19
  const roles = {
17
20
  owner: permissions,
18
- administrator: permissions.filter((p) => p !== "access:write" && !clientPortfolioPermissions.includes(p) && !clientOnboardingPermissions.includes(p) && !billingPermissions.includes(p)),
21
+ administrator: permissions.filter((p) => p !== "access:write" && !clientPortfolioPermissions.includes(p) && !clientOnboardingPermissions.includes(p) && !billingPermissions.includes(p) && !emailCampaignPermissions.includes(p)),
19
22
  editor: ["leads:read", "leads:write", "activity:read", "tasks:read", "tasks:write"],
20
23
  analyst: ["analytics:read", "activity:read"]
21
24
  };
@@ -0,0 +1,58 @@
1
+ export declare const searchTypes: readonly ["web", "image", "video", "news", "discover", "googleNews"];
2
+ export declare const searchDimensions: readonly ["date", "hour", "query", "page", "country", "device", "searchAppearance"];
3
+ export type SearchType = typeof searchTypes[number];
4
+ export interface SearchQuery {
5
+ startDate: string;
6
+ endDate: string;
7
+ type: SearchType;
8
+ dimensions: string[];
9
+ rowLimit: number;
10
+ startRow: number;
11
+ dataState: 'final' | 'all' | 'hourly_all';
12
+ dimensionFilterGroups?: {
13
+ groupType: 'and';
14
+ filters: {
15
+ dimension: string;
16
+ operator: string;
17
+ expression: string;
18
+ }[];
19
+ }[];
20
+ }
21
+ export interface SearchRow {
22
+ keys?: string[];
23
+ clicks: number;
24
+ impressions: number;
25
+ ctr: number;
26
+ position?: number;
27
+ }
28
+ export interface SearchReport {
29
+ source: 'google-search-console';
30
+ property: string;
31
+ timezone: 'America/Los_Angeles';
32
+ request: SearchQuery;
33
+ rows: SearchRow[];
34
+ fetchedAt: string;
35
+ nextStartRow: number | null;
36
+ responseAggregationType?: string;
37
+ metadata?: unknown;
38
+ persisted: boolean;
39
+ limitations: string[];
40
+ }
41
+ export interface SearchConsoleStatus {
42
+ configured: boolean;
43
+ connected: boolean;
44
+ property: string | null;
45
+ revision: number;
46
+ canSubmitSitemaps: boolean;
47
+ lastSyncedAt: string | null;
48
+ error: string | null;
49
+ }
50
+ export interface SearchConsoleAppSettings {
51
+ revision: number;
52
+ clientId: string;
53
+ clientSecretSet: boolean;
54
+ encryptionReady: boolean;
55
+ redirectUri: string;
56
+ source: 'admin' | 'environment' | 'not-configured';
57
+ }
58
+ export declare const searchLimitations: string[];
@@ -0,0 +1,13 @@
1
+ // packages/contracts/src/search-console.ts
2
+ var searchTypes = ["web", "image", "video", "news", "discover", "googleNews"];
3
+ var searchDimensions = ["date", "hour", "query", "page", "country", "device", "searchAppearance"];
4
+ var searchLimitations = [
5
+ "Google returns available top rows and omits anonymized queries. Query and page breakdowns need not add up to property totals.",
6
+ "Daily reports use Pacific time. Final data can lag; fresh and hourly data can be incomplete.",
7
+ "Search clicks and impressions are separate from website visits and enquiries. The API does not expose every Search Console report."
8
+ ];
9
+ export {
10
+ searchDimensions,
11
+ searchLimitations,
12
+ searchTypes
13
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryvitris/website-admin-contracts",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Validated installation and module contracts for Cloudflare business admin apps",
@@ -12,6 +12,10 @@
12
12
  "./scheduling": {
13
13
  "types": "./dist/scheduling.d.ts",
14
14
  "default": "./dist/scheduling.js"
15
+ },
16
+ "./email-campaigns": {
17
+ "types": "./dist/email-campaigns.d.ts",
18
+ "default": "./dist/email-campaigns.js"
15
19
  }
16
20
  },
17
21
  "files": [
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "name": "@tryvitris/website-admin-contracts",
4
- "version": "1.5.0",
5
- "title": "Typed agent maintenance reports and plans",
4
+ "version": "1.7.0",
5
+ "title": "Typed Google search reports and scoped connection management",
6
6
  "notesScope": "package",
7
7
  "notes": [
8
- "Adds the shared package-family, status, release identity and prepare-and-test plan contract used by the admin and website daily maintenance tooling."
8
+ "Adds Search Console query/report/status types, supported search dimensions and types, provenance, Pacific timezone, Google partial-data metadata, pagination and source-limit disclosures.",
9
+ "Adds search-console:manage to owner and administrator role defaults, retains analyst read-only access through analytics:read, and identifies the optional connection separately from business navigation.",
10
+ "Adds the redacted owner application-settings response for in-admin OAuth setup, including revision, callback URL, credential presence and configuration source."
9
11
  ],
10
12
  "repository": "https://github.com/jackperrotta/website-admin-harness",
11
13
  "readmePath": "packages/contracts/README.md",
12
- "ref": "v1.5.0",
13
- "documentation": "# Website Admin Contracts\r\n\r\nValidated installation manifests, roles, permissions and module contracts for `@tryvitris/website-admin-harness`. Node.js 24+. MIT licensed.\r\n\r\nThe maintenance contract exports the stable admin package family and typed installed/locked/published-version reports and preparation plans. Its policy is prepare-and-test under existing publication permissions. It does not execute updates or create schedules; use the runtime maintenance CLI and its agent guide.\r\n",
14
- "documentationSha256": "53077ee5624185df808524801143734e7d9786aab747834e83a8603de06a1ee8"
14
+ "ref": "v1.7.0",
15
+ "documentation": "# Website Admin Contracts\r\n\r\nValidated installation manifests, roles, permissions and module contracts for `@tryvitris/website-admin-harness`. Node.js 24+. MIT licensed.\r\n\r\nThe maintenance contract exports the stable admin package family and typed installed/locked/published-version reports and preparation plans. Its policy is prepare-and-test under existing publication permissions. It does not execute updates or create schedules; use the runtime maintenance CLI and its agent guide.\r\n\r\nThe `./email-campaigns` entry exports validated contacts, evidence and preferences, bounded audience rules, structured templates, communication purposes, guided workflow steps, website events, account cost calculations, and workspace types. Explicit email permissions remain excluded from existing non-owner role defaults.\r\n\r\nGoogle Search Console contracts include performance requests, report provenance/pagination, connection status, supported types/dimensions and `search-console:manage` for connection and sitemap changes.\r\n",
16
+ "documentationSha256": "9202d8eb80f8553185603d394b7790c26c1d5ab2de424d59640f4227b55e9a17"
15
17
  }