d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
import { z } from 'zod'
|
|
import { booleanQueryFlagSchema } from '@/lib/api/contracts/primitives'
|
|
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
|
|
import { isSameOrigin } from '@/lib/core/utils/validation'
|
|
|
|
export const userProfileSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
email: z.string(),
|
|
image: z.string().nullable(),
|
|
emailVerified: z.boolean().optional(),
|
|
})
|
|
|
|
export type UserProfileApiUser = z.output<typeof userProfileSchema>
|
|
|
|
export const getUserProfileContract = defineRouteContract({
|
|
method: 'GET',
|
|
path: '/api/users/me/profile',
|
|
response: {
|
|
mode: 'json',
|
|
schema: z.object({
|
|
user: userProfileSchema,
|
|
}),
|
|
},
|
|
})
|
|
|
|
export const updateUserProfileBodySchema = z
|
|
.object({
|
|
name: z.string().min(1, 'Name is required').optional(),
|
|
image: z
|
|
.string()
|
|
.refine(
|
|
(val) => val.startsWith('http://') || val.startsWith('https://') || val.startsWith('/api/'),
|
|
{ message: 'Invalid image URL' }
|
|
)
|
|
.nullable()
|
|
.optional(),
|
|
})
|
|
.refine((data) => data.name !== undefined || data.image !== undefined, {
|
|
message: 'At least one field (name or image) must be provided',
|
|
})
|
|
|
|
export type UpdateUserProfileBody = z.input<typeof updateUserProfileBodySchema>
|
|
|
|
export const updateUserProfileContract = defineRouteContract({
|
|
method: 'PATCH',
|
|
path: '/api/users/me/profile',
|
|
body: updateUserProfileBodySchema,
|
|
response: {
|
|
mode: 'json',
|
|
schema: z.object({
|
|
success: z.literal(true),
|
|
user: userProfileSchema,
|
|
}),
|
|
},
|
|
})
|
|
|
|
export const userSettingsEmailPreferencesSchema = z.object({
|
|
unsubscribeAll: z.boolean().optional(),
|
|
unsubscribeMarketing: z.boolean().optional(),
|
|
unsubscribeUpdates: z.boolean().optional(),
|
|
unsubscribeNotifications: z.boolean().optional(),
|
|
})
|
|
|
|
export const mothershipEnvironmentSchema = z.enum(['default', 'dev', 'staging', 'prod'])
|
|
export type MothershipEnvironment = z.infer<typeof mothershipEnvironmentSchema>
|
|
|
|
/** An IANA timezone identifier (e.g. `America/New_York`), validated against the runtime's zone database. */
|
|
export const ianaTimezoneSchema = z.string().refine(
|
|
(tz) => {
|
|
try {
|
|
new Intl.DateTimeFormat('en-US', { timeZone: tz })
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
},
|
|
{ message: 'Must be a valid IANA timezone (e.g. America/New_York)' }
|
|
)
|
|
|
|
export const userSettingsSchema = z.object({
|
|
theme: z.enum(['system', 'light', 'dark']).default('system'),
|
|
autoConnect: z.boolean().default(true),
|
|
telemetryEnabled: z.boolean().default(true),
|
|
emailPreferences: userSettingsEmailPreferencesSchema.optional().default({}),
|
|
billingUsageNotificationsEnabled: z.boolean().default(true),
|
|
showTrainingControls: z.boolean().default(false),
|
|
superUserModeEnabled: z.boolean().default(false),
|
|
mothershipEnvironment: mothershipEnvironmentSchema.default('default'),
|
|
errorNotificationsEnabled: z.boolean().default(true),
|
|
snapToGridSize: z.number().min(0).max(50).default(0),
|
|
showActionBar: z.boolean().default(true),
|
|
/** IANA timezone for scheduling; `null` means the client falls back to the browser-detected zone. */
|
|
timezone: z.string().nullable().default(null),
|
|
lastActiveWorkspaceId: z.string().nullable().optional(),
|
|
})
|
|
|
|
export type UserSettingsApi = z.output<typeof userSettingsSchema>
|
|
|
|
export const updateUserSettingsBodySchema = z.object({
|
|
theme: z.enum(['system', 'light', 'dark']).optional(),
|
|
autoConnect: z.boolean().optional(),
|
|
telemetryEnabled: z.boolean().optional(),
|
|
emailPreferences: userSettingsEmailPreferencesSchema.optional(),
|
|
billingUsageNotificationsEnabled: z.boolean().optional(),
|
|
showTrainingControls: z.boolean().optional(),
|
|
superUserModeEnabled: z.boolean().optional(),
|
|
mothershipEnvironment: mothershipEnvironmentSchema.optional(),
|
|
errorNotificationsEnabled: z.boolean().optional(),
|
|
snapToGridSize: z.number().min(0).max(50).optional(),
|
|
showActionBar: z.boolean().optional(),
|
|
/** IANA timezone; explicit `null` resets to the browser-detected zone. */
|
|
timezone: ianaTimezoneSchema.nullable().optional(),
|
|
/** Mirrors `userSettingsSchema.lastActiveWorkspaceId` so explicit `null` is accepted to clear the active workspace. */
|
|
lastActiveWorkspaceId: z.string().nullable().optional(),
|
|
})
|
|
|
|
export const getUserSettingsContract = defineRouteContract({
|
|
method: 'GET',
|
|
path: '/api/users/me/settings',
|
|
response: {
|
|
mode: 'json',
|
|
schema: z.object({
|
|
data: userSettingsSchema,
|
|
}),
|
|
},
|
|
})
|
|
|
|
export const updateUserSettingsContract = defineRouteContract({
|
|
method: 'PATCH',
|
|
path: '/api/users/me/settings',
|
|
body: updateUserSettingsBodySchema,
|
|
response: {
|
|
mode: 'json',
|
|
schema: z.object({
|
|
success: z.literal(true),
|
|
}),
|
|
},
|
|
})
|
|
|
|
export const forgetPasswordBodySchema = z.object({
|
|
email: z.string({ error: 'Email is required' }).email('Please provide a valid email address'),
|
|
redirectTo: z
|
|
.string()
|
|
.optional()
|
|
.or(z.literal(''))
|
|
.transform((val) => (val === '' || val === undefined ? undefined : val))
|
|
.refine(
|
|
(val) => val === undefined || (z.string().url().safeParse(val).success && isSameOrigin(val)),
|
|
{
|
|
message: 'Redirect URL must be a valid same-origin URL',
|
|
}
|
|
),
|
|
})
|
|
|
|
export type ForgetPasswordBody = z.input<typeof forgetPasswordBodySchema>
|
|
|
|
export const forgetPasswordContract = defineRouteContract({
|
|
method: 'POST',
|
|
path: '/api/auth/forget-password',
|
|
body: forgetPasswordBodySchema,
|
|
response: {
|
|
mode: 'json',
|
|
schema: z.object({
|
|
success: z.literal(true),
|
|
}),
|
|
},
|
|
})
|
|
|
|
export const resetPasswordBodySchema = z.object({
|
|
token: z.string({ error: 'Token is required' }).min(1, 'Token is required'),
|
|
newPassword: z
|
|
.string({ error: 'Password is required' })
|
|
.min(8, 'Password must be at least 8 characters long')
|
|
.max(100, 'Password must not exceed 100 characters')
|
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
|
.regex(/[0-9]/, 'Password must contain at least one number')
|
|
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
|
|
})
|
|
|
|
export type ResetPasswordBody = z.input<typeof resetPasswordBodySchema>
|
|
|
|
export const resetPasswordContract = defineRouteContract({
|
|
method: 'POST',
|
|
path: '/api/auth/reset-password',
|
|
body: resetPasswordBodySchema,
|
|
response: {
|
|
mode: 'json',
|
|
schema: z.object({
|
|
success: z.literal(true),
|
|
}),
|
|
},
|
|
})
|
|
|
|
export const unsubscribeBodySchema = z.object({
|
|
email: z.string().email('Invalid email address'),
|
|
token: z.string().min(1, 'Token is required'),
|
|
type: z.enum(['all', 'marketing', 'updates', 'notifications']).optional().default('all'),
|
|
})
|
|
|
|
export const unsubscribeQuerySchema = z.object({
|
|
email: z.string().min(1),
|
|
token: z.string().min(1),
|
|
})
|
|
|
|
const unsubscribePreferencesSchema = z
|
|
.object({
|
|
unsubscribeAll: z.boolean().optional(),
|
|
unsubscribeMarketing: z.boolean().optional(),
|
|
unsubscribeUpdates: z.boolean().optional(),
|
|
unsubscribeNotifications: z.boolean().optional(),
|
|
})
|
|
.passthrough()
|
|
|
|
export const unsubscribeGetResponseSchema = z.object({
|
|
success: z.literal(true),
|
|
email: z.string(),
|
|
token: z.string(),
|
|
emailType: z.string(),
|
|
isTransactional: z.boolean(),
|
|
currentPreferences: unsubscribePreferencesSchema,
|
|
})
|
|
|
|
export const unsubscribeActionResponseSchema = z.object({
|
|
success: z.literal(true),
|
|
message: z.string(),
|
|
email: z.string(),
|
|
type: z.enum(['all', 'marketing', 'updates', 'notifications']),
|
|
emailType: z.string(),
|
|
})
|
|
|
|
export const unsubscribeGetContract = defineRouteContract({
|
|
method: 'GET',
|
|
path: '/api/users/me/settings/unsubscribe',
|
|
query: unsubscribeQuerySchema,
|
|
response: {
|
|
mode: 'json',
|
|
schema: unsubscribeGetResponseSchema,
|
|
},
|
|
})
|
|
|
|
export const unsubscribeFormContract = defineRouteContract({
|
|
method: 'POST',
|
|
path: '/api/users/me/settings/unsubscribe',
|
|
query: unsubscribeQuerySchema,
|
|
response: {
|
|
mode: 'json',
|
|
schema: unsubscribeActionResponseSchema,
|
|
},
|
|
})
|
|
|
|
export const unsubscribePostContract = defineRouteContract({
|
|
method: 'POST',
|
|
path: '/api/users/me/settings/unsubscribe',
|
|
body: unsubscribeBodySchema,
|
|
response: {
|
|
mode: 'json',
|
|
schema: unsubscribeActionResponseSchema,
|
|
},
|
|
})
|
|
|
|
export type UnsubscribeData = ContractJsonResponse<typeof unsubscribeGetContract>
|
|
export type UnsubscribeActionResponse = ContractJsonResponse<typeof unsubscribePostContract>
|
|
export type UnsubscribeBody = z.input<typeof unsubscribeBodySchema>
|
|
export type UnsubscribeType = NonNullable<UnsubscribeBody['type']>
|
|
|
|
export const usageLogSourceSchema = z.enum([
|
|
'workflow',
|
|
'wand',
|
|
'copilot',
|
|
'workspace-chat',
|
|
'mcp_copilot',
|
|
'mothership_block',
|
|
'knowledge-base',
|
|
'voice-input',
|
|
'enrichment',
|
|
])
|
|
|
|
export const usageLogPeriodSchema = z.enum(['1d', '7d', '30d', 'all', 'custom'])
|
|
|
|
/**
|
|
* `Date`-constructor-parseable string — the {@link Calendar} range picker
|
|
* emits local `YYYY-MM-DDTHH:mm`, not strict ISO 8601, so this validates
|
|
* parseability rather than a specific wire format.
|
|
*/
|
|
const parseableDateSchema = z
|
|
.string()
|
|
.min(1)
|
|
.refine((value) => !Number.isNaN(Date.parse(value)), { error: 'Invalid date' })
|
|
|
|
/** Shared by the paginated list query and the export query — filters only, no pagination. */
|
|
const usageLogsFilterSchema = z.object({
|
|
source: usageLogSourceSchema.optional(),
|
|
workspaceId: z.string().optional(),
|
|
period: usageLogPeriodSchema.optional().default('30d'),
|
|
/** Required when `period` is `'custom'`. */
|
|
startDate: parseableDateSchema.optional(),
|
|
/** Defaults to now when omitted for `'custom'`. */
|
|
endDate: parseableDateSchema.optional(),
|
|
})
|
|
|
|
/** Both the list and export query schemas require startDate whenever period is 'custom'. */
|
|
const startDateRequiredForCustomPeriod = {
|
|
error: 'startDate is required when period is "custom"',
|
|
path: ['startDate'],
|
|
}
|
|
|
|
export const usageLogsQuerySchema = usageLogsFilterSchema
|
|
.extend({
|
|
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
|
cursor: z.string().optional(),
|
|
/**
|
|
* The compact Billing summary glance (`limit: 1`) only reads
|
|
* `summary.totalCredits`, never a row's `creditCost` — set `false` to
|
|
* skip the whole-filter apportionment scan for that caller.
|
|
*/
|
|
includeCredits: booleanQueryFlagSchema.optional().default(true),
|
|
})
|
|
.refine(
|
|
(query) => query.period !== 'custom' || query.startDate !== undefined,
|
|
startDateRequiredForCustomPeriod
|
|
)
|
|
|
|
/** Same filters as the list query, without pagination — the export route returns every match. */
|
|
export const exportUsageLogsQuerySchema = usageLogsFilterSchema.refine(
|
|
(query) => query.period !== 'custom' || query.startDate !== undefined,
|
|
startDateRequiredForCustomPeriod
|
|
)
|
|
|
|
export const usageLogEntrySchema = z.object({
|
|
id: z.string(),
|
|
createdAt: z.string(),
|
|
source: usageLogSourceSchema,
|
|
/** Specific workflow name, populated only when `source` is `'workflow'`. */
|
|
workflowName: z.string().nullable(),
|
|
/**
|
|
* Credit-denominated cost of this event (Sim's usage unit; 1,000 credits =
|
|
* $5), apportioned across the page so row credits always sum exactly to
|
|
* the page's rounded total — this can legitimately be 0 for a row with a
|
|
* real but sub-credit `dollarCost` once a sibling row absorbs the shared
|
|
* rounding remainder.
|
|
*/
|
|
creditCost: z.number(),
|
|
/** Raw dollar cost, so a 0 `creditCost` can be distinguished from a genuinely free event. */
|
|
dollarCost: z.number(),
|
|
})
|
|
|
|
export const usageLogsApiResponseSchema = z.object({
|
|
success: z.boolean(),
|
|
logs: z.array(usageLogEntrySchema),
|
|
summary: z.object({
|
|
totalCredits: z.number(),
|
|
bySourceCredits: z.record(z.string(), z.number()),
|
|
}),
|
|
pagination: z.object({
|
|
nextCursor: z.string().optional(),
|
|
hasMore: z.boolean(),
|
|
}),
|
|
})
|
|
|
|
export const getUsageLogsContract = defineRouteContract({
|
|
method: 'GET',
|
|
path: '/api/users/me/usage-logs',
|
|
query: usageLogsQuerySchema,
|
|
response: {
|
|
mode: 'json',
|
|
schema: usageLogsApiResponseSchema,
|
|
},
|
|
})
|
|
|
|
/**
|
|
* CSV download of every usage log matching the filter (no pagination). `mode:
|
|
* 'text'` because a CSV response has no JSON schema to validate; the client
|
|
* triggers this as a plain browser download (an anchor navigation), never
|
|
* through `requestJson`, so there's no response shape for a consumer to type.
|
|
*/
|
|
export const exportUsageLogsContract = defineRouteContract({
|
|
method: 'GET',
|
|
path: '/api/users/me/usage-logs/export',
|
|
query: exportUsageLogsQuerySchema,
|
|
response: {
|
|
mode: 'text',
|
|
},
|
|
})
|
|
|
|
export type UsageLogSource = z.output<typeof usageLogSourceSchema>
|
|
export type UsageLogPeriod = z.output<typeof usageLogPeriodSchema>
|
|
export type UsageLogEntry = z.output<typeof usageLogEntrySchema>
|
|
export type UsageLogsApiResponse = z.output<typeof usageLogsApiResponseSchema>
|
|
export type ExportUsageLogsQuery = z.output<typeof exportUsageLogsQuerySchema>
|
|
|
|
export const subscriptionTransferParamsSchema = z.object({
|
|
id: z.string({ error: 'Subscription ID is required' }).min(1, 'Subscription ID is required'),
|
|
})
|
|
|
|
export const subscriptionTransferBodySchema = z.object({
|
|
organizationId: z
|
|
.string({ error: 'organizationId is required' })
|
|
.min(1, 'organizationId is required'),
|
|
})
|
|
|
|
export const subscriptionTransferContract = defineRouteContract({
|
|
method: 'POST',
|
|
path: '/api/users/me/subscription/[id]/transfer',
|
|
params: subscriptionTransferParamsSchema,
|
|
body: subscriptionTransferBodySchema,
|
|
response: {
|
|
mode: 'json',
|
|
schema: z.object({
|
|
success: z.literal(true),
|
|
message: z.string(),
|
|
}),
|
|
},
|
|
})
|