Implement Workstream F1: me.get and me.setupProfile procedures

- Add me.get procedure returning user profile with needsSetup flag
- Add me.setupProfile procedure for initial profile setup after signup
- Add nonEmptyString/optionalString schema helpers with tests
- Use Web Crypto API (SubtleCrypto) for Cloudflare Workers compatibility
- Use @formatjs/intl-durationformat for duration formatting
- Remove node:crypto dependency from crypto utilities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-09 16:29:41 +08:00
parent 93851afe38
commit 860d791125
16 changed files with 205 additions and 61 deletions

View File

@@ -2,6 +2,9 @@ import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
ignores: ["**/*.test.ts"],
},
{
languageOptions: {
parserOptions: {

View File

@@ -12,6 +12,7 @@
},
"scripts": {
"build": "tsc",
"test": "bun test",
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
"lint": "eslint . --cache"
},

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test";
import { nonEmptyString, optionalString } from "./common.js";
describe("nonEmptyString", () => {
const schema = nonEmptyString(100);
test("accepts valid non-empty string", () => {
expect(schema.parse("hello")).toBe("hello");
});
test("trims whitespace", () => {
expect(schema.parse(" hello ")).toBe("hello");
});
test("rejects empty string", () => {
expect(() => schema.parse("")).toThrow();
});
test("rejects whitespace-only string", () => {
expect(() => schema.parse(" ")).toThrow();
});
test("rejects string exceeding max length", () => {
const shortSchema = nonEmptyString(5);
expect(() => shortSchema.parse("123456")).toThrow();
});
test("accepts string at max length", () => {
const shortSchema = nonEmptyString(5);
expect(shortSchema.parse("12345")).toBe("12345");
});
test("works without max length", () => {
const noMaxSchema = nonEmptyString();
expect(noMaxSchema.parse("a".repeat(1000))).toBe("a".repeat(1000));
});
});
describe("optionalString", () => {
const schema = optionalString(200);
test("accepts valid non-empty string", () => {
expect(schema.parse("hello")).toBe("hello");
});
test("trims whitespace", () => {
expect(schema.parse(" hello ")).toBe("hello");
});
test("transforms empty string to undefined", () => {
expect(schema.parse("")).toBeUndefined();
});
test("transforms whitespace-only string to undefined", () => {
expect(schema.parse(" ")).toBeUndefined();
});
test("accepts undefined input", () => {
expect(schema.parse(undefined)).toBeUndefined();
});
test("rejects string exceeding max length", () => {
const shortSchema = optionalString(5);
expect(() => shortSchema.parse("123456")).toThrow();
});
test("accepts string at max length", () => {
const shortSchema = optionalString(5);
expect(shortSchema.parse("12345")).toBe("12345");
});
test("works without max length", () => {
const noMaxSchema = optionalString();
expect(noMaxSchema.parse("a".repeat(1000))).toBe("a".repeat(1000));
});
test("transforms empty to undefined without max length", () => {
const noMaxSchema = optionalString();
expect(noMaxSchema.parse("")).toBeUndefined();
});
});

View File

@@ -4,6 +4,25 @@ import {
} from "libphonenumber-js";
import * as z from "zod";
/**
* Non-empty string schema - trims whitespace and ensures at least 1 char
* Use for required text fields that shouldn't be blank
*/
export const nonEmptyString = (maxLength?: number) => {
const base = z.string().trim().min(1);
return maxLength ? base.max(maxLength) : base;
};
/**
* Optional non-empty string - trims and converts empty/whitespace to undefined
* Use for optional text fields where blank should be treated as not provided
*/
export const optionalString = (maxLength?: number) => {
const base = z.string().trim();
const withMax = maxLength ? base.max(maxLength) : base;
return withMax.optional().transform((v) => (v === "" ? undefined : v));
};
/**
* Email schema - validates email format and transforms to lowercase
*/

View File

@@ -1,5 +1,5 @@
import * as z from "zod";
import { phoneSchema } from "./common.js";
import { nonEmptyString, optionalString, phoneSchema } from "./common.js";
/**
* User profile schema
@@ -22,8 +22,8 @@ export const userProfileSchema = z.object({
* Used after signup to collect profile information
*/
export const setupProfileInputSchema = z.object({
displayName: z.string().min(1).max(100),
fullName: z.string().max(200).optional(),
displayName: nonEmptyString(100),
fullName: optionalString(200),
phoneNumber: phoneSchema,
});
@@ -32,10 +32,10 @@ export const setupProfileInputSchema = z.object({
* All fields optional for partial updates
*/
export const updateProfileInputSchema = z.object({
displayName: z.string().min(1).max(100).optional(),
fullName: z.string().max(200).optional(),
displayName: nonEmptyString(100).optional(),
fullName: optionalString(200),
phoneNumber: phoneSchema,
avatarUrl: z.string().optional(),
avatarUrl: optionalString(),
});
/**
@@ -95,5 +95,5 @@ export const deviceOutputSchema = z.object({
* Used to name and trust the current device
*/
export const trustDeviceInputSchema = z.object({
name: z.string().min(1).max(100),
name: nonEmptyString(100),
});

View File

@@ -2,5 +2,6 @@
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"isolatedDeclarations": false
}
},
"exclude": ["**/*.test.ts"]
}