feat: implement user profile management with avatars, login, and personal data
- Add avatar upload functionality with public URL access. - Implement user login with email and password authentication. - Create endpoints for fetching and updating the authenticated user's profile. - Set up database migrations for user profiles, including email and role management. - Introduce personal data management linked to user profiles. - Add email verification process with welcome email templates.
This commit is contained in:
327
frontend/tools/zod-sync.mjs
Normal file
327
frontend/tools/zod-sync.mjs
Normal file
@@ -0,0 +1,327 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const clientPath = path.join(rootDir, 'src/encore/client.ts');
|
||||
const outputPath = path.join(rootDir, 'src/encore/zod.ts');
|
||||
|
||||
const clientSource = fs.readFileSync(clientPath, 'utf8');
|
||||
const clientFile = ts.createSourceFile(clientPath, clientSource, ts.ScriptTarget.Latest, true);
|
||||
const existingSource = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : '';
|
||||
const existingFile = ts.createSourceFile(outputPath, existingSource, ts.ScriptTarget.Latest, true);
|
||||
|
||||
const schemas = collectEncoreSchemas(clientFile);
|
||||
const existingSchemas = collectExistingSchemas(existingFile, existingSource);
|
||||
const generated = renderOutput(schemas, existingSchemas);
|
||||
|
||||
fs.writeFileSync(outputPath, generated);
|
||||
console.log(`Synced ${schemas.length} Zod schemas to ${path.relative(rootDir, outputPath)}`);
|
||||
|
||||
function collectEncoreSchemas(sourceFile) {
|
||||
const found = [];
|
||||
const typeAliases = new Map();
|
||||
const writableParamTypes = collectWritableParamTypes(sourceFile);
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!isExportedNamespace(statement)) continue;
|
||||
const namespaceName = statement.name.text;
|
||||
const body = statement.body;
|
||||
if (!body || !ts.isModuleBlock(body)) continue;
|
||||
|
||||
for (const child of body.statements) {
|
||||
if (ts.isTypeAliasDeclaration(child) && isExported(child)) {
|
||||
typeAliases.set(`${namespaceName}.${child.name.text}`, child.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!isExportedNamespace(statement)) continue;
|
||||
const namespaceName = statement.name.text;
|
||||
const body = statement.body;
|
||||
if (!body || !ts.isModuleBlock(body)) continue;
|
||||
|
||||
for (const child of body.statements) {
|
||||
const qualifiedName = `${namespaceName}.${child.name?.text ?? ''}`;
|
||||
if (!writableParamTypes.has(qualifiedName)) continue;
|
||||
|
||||
if (ts.isInterfaceDeclaration(child) && isExported(child)) {
|
||||
found.push({
|
||||
kind: 'object',
|
||||
namespaceName,
|
||||
typeName: child.name.text,
|
||||
schemaName: schemaName(namespaceName, child.name.text),
|
||||
fields: child.members
|
||||
.filter(ts.isPropertySignature)
|
||||
.map((member) => ({
|
||||
name: propertyName(member.name),
|
||||
zod: zodForType(member.type, namespaceName, typeAliases),
|
||||
}))
|
||||
.filter((field) => field.name),
|
||||
});
|
||||
}
|
||||
|
||||
if (ts.isTypeAliasDeclaration(child) && isExported(child)) {
|
||||
found.push({
|
||||
kind: 'alias',
|
||||
namespaceName,
|
||||
typeName: child.name.text,
|
||||
schemaName: schemaName(namespaceName, child.name.text),
|
||||
zod: zodForType(child.type, namespaceName, typeAliases),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function collectWritableParamTypes(sourceFile) {
|
||||
const found = new Set();
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!isExportedNamespace(statement)) continue;
|
||||
const namespaceName = statement.name.text;
|
||||
const body = statement.body;
|
||||
if (!body || !ts.isModuleBlock(body)) continue;
|
||||
|
||||
for (const child of body.statements) {
|
||||
if (!ts.isClassDeclaration(child) || child.name?.text !== 'ServiceClient') continue;
|
||||
|
||||
for (const member of child.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue;
|
||||
const httpMethod = writableHttpMethod(member);
|
||||
if (!httpMethod) continue;
|
||||
|
||||
const bodyParamName = jsonStringifiedParamName(member);
|
||||
if (!bodyParamName) continue;
|
||||
|
||||
const param = member.parameters.find((candidate) => propertyName(candidate.name) === bodyParamName);
|
||||
if (!param?.type || !ts.isTypeReferenceNode(param.type)) continue;
|
||||
|
||||
const ref = typeNameText(param.type.typeName);
|
||||
const qualifiedRef = ref.includes('.') ? ref : `${namespaceName}.${ref}`;
|
||||
found.add(qualifiedRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function writableHttpMethod(method) {
|
||||
let result = '';
|
||||
|
||||
visit(method.body);
|
||||
return result;
|
||||
|
||||
function visit(node) {
|
||||
if (result || !node) return;
|
||||
|
||||
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
||||
const callName = node.expression.name.text;
|
||||
if (callName === 'callTypedAPI' || callName === 'callAPI') {
|
||||
const [methodArg] = node.arguments;
|
||||
if (methodArg && ts.isStringLiteral(methodArg) && ['POST', 'PUT'].includes(methodArg.text)) {
|
||||
result = methodArg.text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonStringifiedParamName(method) {
|
||||
let result = '';
|
||||
|
||||
visit(method.body);
|
||||
return result;
|
||||
|
||||
function visit(node) {
|
||||
if (result || !node) return;
|
||||
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
ts.isIdentifier(node.expression.expression) &&
|
||||
node.expression.expression.text === 'JSON' &&
|
||||
node.expression.name.text === 'stringify'
|
||||
) {
|
||||
const [arg] = node.arguments;
|
||||
if (arg && ts.isIdentifier(arg)) {
|
||||
result = arg.text;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function collectExistingSchemas(sourceFile, sourceText) {
|
||||
const result = new Map();
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (!ts.isVariableStatement(statement) || !isExported(statement)) continue;
|
||||
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
|
||||
if (!declaration.name.text.endsWith('Schema')) continue;
|
||||
|
||||
const objectLiteral = zodObjectLiteral(declaration.initializer);
|
||||
if (!objectLiteral) continue;
|
||||
|
||||
const fields = new Map();
|
||||
for (const prop of objectLiteral.properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) continue;
|
||||
const name = propertyName(prop.name);
|
||||
if (!name) continue;
|
||||
fields.set(name, prop.initializer.getText(sourceFile));
|
||||
}
|
||||
|
||||
result.set(declaration.name.text, { fields });
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceText.trim()) return result;
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderOutput(schemas, existingSchemas) {
|
||||
const lines = [
|
||||
'// Code synced from src/encore/client.ts by frontend/tools/zod-sync.mjs.',
|
||||
'// Existing field validators are preserved when this file is synced again.',
|
||||
"import { z } from 'zod';",
|
||||
'',
|
||||
];
|
||||
|
||||
for (const schema of schemas) {
|
||||
if (schema.kind === 'alias') {
|
||||
lines.push(`export const ${schema.schemaName} = ${schema.zod};`);
|
||||
lines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = existingSchemas.get(schema.schemaName);
|
||||
const currentNames = new Set(schema.fields.map((field) => field.name));
|
||||
lines.push(`export const ${schema.schemaName} = z.object({`);
|
||||
for (const field of schema.fields) {
|
||||
const expression = existing?.fields.get(field.name) ?? field.zod;
|
||||
lines.push(` ${quoteKey(field.name)}: ${expression},`);
|
||||
}
|
||||
for (const [name, expression] of existing?.fields ?? []) {
|
||||
if (currentNames.has(name)) continue;
|
||||
lines.push(` // TODO: no longer present in Encore type ${schema.namespaceName}.${schema.typeName}`);
|
||||
lines.push(` ${quoteKey(name)}: ${expression},`);
|
||||
}
|
||||
lines.push('});');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return `${lines.join('\n').trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function zodObjectLiteral(initializer) {
|
||||
if (!ts.isCallExpression(initializer)) return null;
|
||||
if (!ts.isPropertyAccessExpression(initializer.expression)) return null;
|
||||
if (initializer.expression.name.text !== 'object') return null;
|
||||
if (!ts.isIdentifier(initializer.expression.expression)) return null;
|
||||
if (initializer.expression.expression.text !== 'z') return null;
|
||||
const [arg] = initializer.arguments;
|
||||
return arg && ts.isObjectLiteralExpression(arg) ? arg : null;
|
||||
}
|
||||
|
||||
function zodForType(type, namespaceName, typeAliases) {
|
||||
if (!type) return 'z.unknown()';
|
||||
|
||||
if (type.kind === ts.SyntaxKind.StringKeyword) return 'z.string()';
|
||||
if (type.kind === ts.SyntaxKind.NumberKeyword) return 'z.number()';
|
||||
if (type.kind === ts.SyntaxKind.BooleanKeyword) return 'z.boolean()';
|
||||
if (type.kind === ts.SyntaxKind.AnyKeyword) return 'z.any()';
|
||||
if (type.kind === ts.SyntaxKind.UnknownKeyword) return 'z.unknown()';
|
||||
|
||||
if (ts.isArrayTypeNode(type)) {
|
||||
return `z.array(${zodForType(type.elementType, namespaceName, typeAliases)})`;
|
||||
}
|
||||
|
||||
if (ts.isUnionTypeNode(type)) {
|
||||
const literals = type.types.filter(ts.isLiteralTypeNode);
|
||||
if (literals.length === type.types.length && literals.length > 0) {
|
||||
return `z.union([${literals.map((literal) => zodLiteral(literal)).join(', ')}])`;
|
||||
}
|
||||
return 'z.unknown()';
|
||||
}
|
||||
|
||||
if (ts.isTypeLiteralNode(type)) {
|
||||
const fields = type.members
|
||||
.filter(ts.isPropertySignature)
|
||||
.map((member) => `${quoteKey(propertyName(member.name))}: ${zodForType(member.type, namespaceName, typeAliases)}`);
|
||||
return `z.object({ ${fields.join(', ')} })`;
|
||||
}
|
||||
|
||||
if (ts.isTypeReferenceNode(type)) {
|
||||
const ref = typeNameText(type.typeName);
|
||||
const qualifiedRef = ref.includes('.') ? ref : `${namespaceName}.${ref}`;
|
||||
const aliasType = typeAliases.get(qualifiedRef);
|
||||
if (aliasType) return zodForType(aliasType, namespaceName, typeAliases);
|
||||
return `z.lazy(() => ${schemaNameFromReference(ref, namespaceName)})`;
|
||||
}
|
||||
|
||||
return 'z.unknown()';
|
||||
}
|
||||
|
||||
function zodLiteral(literal) {
|
||||
const node = literal.literal;
|
||||
if (ts.isStringLiteral(node)) return `z.literal(${JSON.stringify(node.text)})`;
|
||||
if (ts.isNumericLiteral(node)) return `z.literal(${node.text})`;
|
||||
if (node.kind === ts.SyntaxKind.TrueKeyword) return 'z.literal(true)';
|
||||
if (node.kind === ts.SyntaxKind.FalseKeyword) return 'z.literal(false)';
|
||||
return 'z.unknown()';
|
||||
}
|
||||
|
||||
function schemaNameFromReference(ref, namespaceName) {
|
||||
if (ref.includes('.')) {
|
||||
const [ns, name] = ref.split('.');
|
||||
return schemaName(ns, name);
|
||||
}
|
||||
return schemaName(namespaceName, ref);
|
||||
}
|
||||
|
||||
function schemaName(namespaceName, typeName) {
|
||||
return `${pascal(namespaceName)}${pascal(typeName)}Schema`;
|
||||
}
|
||||
|
||||
function propertyName(name) {
|
||||
if (!name) return '';
|
||||
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
|
||||
return '';
|
||||
}
|
||||
|
||||
function quoteKey(key) {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
function typeNameText(name) {
|
||||
if (ts.isIdentifier(name)) return name.text;
|
||||
if (ts.isQualifiedName(name)) return `${typeNameText(name.left)}.${name.right.text}`;
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function pascal(value) {
|
||||
return value
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function isExportedNamespace(node) {
|
||||
return ts.isModuleDeclaration(node) && isExported(node) && ts.isIdentifier(node.name);
|
||||
}
|
||||
|
||||
function isExported(node) {
|
||||
return Boolean(node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
|
||||
}
|
||||
Reference in New Issue
Block a user