@firstlovecenter/flc-profile
v0.5.0
Published
Reusable Google-Forms-style profile system for FLC Next.js + Prisma apps. Customizable fields, CRUD, archive/restore, drag-to-reorder field builder.
Maintainers
Readme
@firstlovecenter/flc-profile
A reusable Google-Forms-style profile system for First Love Centre Next.js + Prisma apps. Admin-configurable fields, CRUD, archive/restore, drag-to-reorder field builder, dynamic Zod validation, and themed UI components — all wired together with port-based DI so the host owns persistence, auth, storage, and audit.
Status: stable. Server (routes + Prisma/memory persistence) and the UI components are implemented. As of 0.4.0 the system is namespace-aware: one deployment can host independent field sets (see Namespaces), and
ProfileFieldcarries a host-definedsemanticRolemarker. Backward-compatible — consumers that pass no namespace behave exactly as before (namespace"default").
Sibling packages
@firstlovecenter/milestone-grid— Subject × Milestone gate grid with file uploads.@firstlovecenter/ai-chat— Vertex AI agent loop + persistence +<AiChat>UI.
This package is the third in the trio: same architecture, same configure* entry point, same <...> UI surface.
Contents
- Quick start
- Concepts
- Namespaces
- Field markers
- Field types reference
- Upgrading to namespaces (0.4.0)
- Ports
- Theming
- Field-schema migration guarantees
- Reuse in another app
- API reference
- Type reference
- Architecture
Quick start
1. Install
pnpm add @firstlovecenter/flc-profilePeer deps: @prisma/client >=5, next >=15, react >=18, react-dom >=18, zod >=3.
2. Paste the Prisma snippet
Add the contents of src/schema/profile.prisma (shipped in the package) to your prisma/schema.prisma. Run pnpm prisma migrate dev --name add_profile.
3. Configure the server
src/lib/profile/configure.ts:
import { configureProfile, createPrismaPersistence } from "@firstlovecenter/flc-profile/server";
import { prisma } from "@/lib/prisma";
import { authPort } from "@/lib/auth/port";
import { storagePort } from "@/lib/storage/r2";
export const profile = configureProfile({
persistence: createPrismaPersistence(prisma),
auth: authPort,
storage: storagePort,
routeBasePath: "/api/profile",
});For a namespaced deployment, configure one instance per field set and mount each under its own base path — see Namespaces.
4. Mount the routes
For each handler the package exposes, create a thin re-export under app/api/profile/**:
// app/api/profile/profiles/route.ts
export const GET = profile.routes.profilesList.GET;
export const POST = profile.routes.profilesList.POST;
// app/api/profile/profiles/[id]/route.ts
export const GET = profile.routes.profileItem.GET;
export const PATCH = profile.routes.profileItem.PATCH;
export const DELETE = profile.routes.profileItem.DELETE;
// ...and so on for /profiles/[id]/restore, /profiles/[id]/photo,
// /fields, /fields/[id], /fields/reorder.5. Import the CSS
app/globals.css:
@import "@firstlovecenter/flc-profile/styles.css";
@import "tailwindcss";
@import "tw-animate-css";
@import "@firstlovecenter/flc-profile/theme.css";Order matters — structural CSS before Tailwind so package internals survive preflight; theme CSS after so host overrides win.
6. Mount UI components
// app/missionaries/page.tsx
import { ProfileList } from "@firstlovecenter/flc-profile/ui";
export default function MissionariesPage() {
// Defaults to basePath "/api/profile"; pass basePath="/api/profile/estate"
// (etc.) for a namespaced mount.
return <ProfileList />;
}// app/admin/profile-fields/page.tsx
import { ProfileFieldBuilder } from "@firstlovecenter/flc-profile/ui";
export default function AdminFieldsPage() {
return (
<ProfileFieldBuilder
allowedSemanticRoles={[{ value: "occupant_type", label: "Occupant type" }]}
/>
);
}That's it. You now have an admin-editable profile system with archive, restore, drag-to-reorder, and dynamic form validation.
UI component props (all components accept an optional client or basePath; default basePath is /api/profile):
| Component | Key props |
| --- | --- |
| <ProfileList> | basePath?, client?, onSelect? |
| <ProfileView> | profileId, basePath?, client?, resolvePhotoUrl?, renderGallery? |
| <ProfileForm> | profileId? (omit to create), basePath?, client?, onSaved?, resolvePhotoUrl?, renderGalleryEditor? |
| <ProfileFieldBuilder> | basePath?, client?, allowedSemanticRoles?, onChange? |
| <ProfileCard> | profile, fields, resolvePhotoUrl?, onClick? (presentational — takes data, does not fetch) |
| <ArchivedProfiles> | basePath?, client?, onRestored? |
resolvePhotoUrl(objectKey) => string maps a stored R2 object key to a servable URL (the package can't know your CDN base). PHOTO/FILE/GALLERY uploads in <ProfileForm> require an existing profile, so they activate in edit mode (profileId set); createProfileClient(...).uploadPhoto(profileId, fieldKey, file) presigns and PUTs in one call.
Galleries & custom rendering
A GALLERY field holds many photos — stored as an ordered GalleryItem[] ({ key, caption? }) in
customFields[fieldKey]. Out of the box it renders a thumbnail grid (read-only in <ProfileView>, a
file-picker + per-photo caption/remove in <ProfileForm>), so you don't have to build anything.
When you want your own look, pass a render-prop. The package keeps managing the photos (upload, remove, reorder, R2 cleanup on save); you only own the layout:
// Read-only: render galleries however you like (carousel, lightbox, masonry…)
<ProfileView
profileId={id}
resolvePhotoUrl={(key) => `${CDN}/${key}`}
renderGallery={({ items, resolvePhotoUrl }) => (
<MyCarousel slides={items.map((it) => ({ src: resolvePhotoUrl!(it.key), caption: it.caption }))} />
)}
/>
// Editor: your layout, the package's photo management
<ProfileForm
profileId={id}
resolvePhotoUrl={(key) => `${CDN}/${key}`}
renderGalleryEditor={({ items, resolvePhotoUrl, addFiles, remove, updateCaption, reorder, uploading }) => (
<MyGalleryEditor
items={items}
resolve={resolvePhotoUrl}
onAdd={addFiles} // (files) => Promise<void> — presigns + uploads + appends
onRemove={remove} // (key) => void — purged from R2 on save
onCaption={updateCaption}
onReorder={reorder}
busy={uploading}
/>
)}
/>Removing a photo and saving physically deletes the R2 object (best-effort; a storage failure never
fails the save). See the storage-adapter note in docs/host-integration.md — gallery adapters must
fold the per-upload unique token into stampKey.
Concepts
| Concept | What it is |
| --- | --- |
| Profile | One row per person (or other subject). Holds a JSON customFields blob whose shape is defined at runtime by the ProfileField schema. |
| ProfileField | A field definition (admin-editable). key, label, type, options, required, section, etc. Adding/editing fields does NOT rewrite existing Profile.customFields data. |
| ProfileFieldChange | Audit row written every time a ProfileField is created / edited / archived. |
| Section | Grouping label for fields in <ProfileView> and <ProfileForm> ("Identity", "Contact", "Family", etc.). Admin-editable. |
| Primary label | One or more fields marked isPrimaryLabel = true. Their concatenated values become the profile's display name (e.g., first + last → "Adwoa Mensah"). |
| Archive | Soft-delete. archivedAt is set; the row stays in the DB; lists filter it out by default. Restore reverses. |
| Namespace | Field-set discriminator (default "default"). Lets one deployment host independent field sets. Keys are unique within a namespace. |
Namespaces
A single deployment can host independent field sets — e.g. one for estates and one for apartments — by configuring one instance per namespace. Each instance scopes every read and write to its namespace, so a profile or field in one namespace is invisible to another.
// src/lib/profile/estate.ts
export const estateProfile = configureProfile({
namespace: "ESTATE",
persistence: createPrismaPersistence(prisma, { namespace: "ESTATE" }),
auth: authPort,
storage: storagePort,
routeBasePath: "/api/profile/estate",
});
// src/lib/profile/apartment.ts — identical with namespace "APARTMENT",
// mounted under /api/profile/apartmentNotes:
- The persistence adapter is the source of truth for scoping:
createPrismaPersistence(prisma, { namespace })filters every query, rewritesgetFieldByKeyto the@@unique([namespace, key])composite, and fences find/update-by-id so a foreign-namespace id can't be read or mutated.configureProfile({ namespace })mirrors it on the handler context for hooks/diagnostics. - The mount path separates instances. Mount each instance's routes under a distinct base path (
/api/profile/estate/**,/api/profile/apartment/**).routeBasePathis metadata; what actually routes a request is where you mount the handler. On the client, pass the matchingbasePathto the UI components /createProfileClient. - The same
keymay exist in two namespaces (e.g. both have anamefield) without colliding. - Omitting
namespaceeverywhere keeps the pre-0.4.0 single-namespace behaviour ("default").
Field markers
Three boolean/string markers on ProfileField drive host behaviour; all are set in <ProfileFieldBuilder>:
isPrimaryLabel— fields concatenated (insortOrder) into the profile's display name viacomposePrimaryLabel.isProfilePhoto— designates the singlePHOTOfield used as the profile's cover image (enforced unique per namespace).semanticRole(0.4.0) — a nullable, host-defined tag (e.g."occupant_type"). The server accepts any string; the builder restricts choices to the host-suppliedallowedSemanticRoles. Use it to let the host find a field by role for charts/aggregation without hard-coding its key.
Field types reference
| Type | Form input | Persists as | Zod schema (active) |
| --- | --- | --- | --- |
| TEXT | <input type="text"> | string | z.string().min(required ? 1 : 0) |
| LONG_TEXT | <textarea> | string | z.string() |
| NUMBER | <input type="number"> | number | z.coerce.number() |
| BOOLEAN | shadcn <Checkbox> | boolean | z.coerce.boolean() |
| DATE | <input type="date"> (custom) | "YYYY-MM-DD" string | z.string().regex(/^\d{4}-\d{2}-\d{2}$/) |
| DATETIME | datetime picker | ISO string | z.string().datetime() |
| EMAIL | <input type="email"> | string | z.string().email() |
| PHONE | <input type="tel"> | string | regex /^\+?[0-9 \-()]+$/ |
| URL | <input type="url"> | string | z.string().url() |
| SELECT | shadcn <Select> | option value | z.enum([...options.value]) |
| MULTI_SELECT | tag-style multi | string[] | z.array(z.enum(...)) |
| FILE | presigned-PUT upload | R2 objectKey | z.string() |
| PHOTO | presigned-PUT upload, image preview | R2 objectKey | z.string() |
| GALLERY | multi-image upload, thumbnail grid + captions | { key, caption? }[] (array order = display order) | z.array(z.object({ key, caption? })) |
| MARKDOWN | <textarea> + preview | string | z.string() |
required = false wraps the schema with .optional().nullable().
options (for SELECT / MULTI_SELECT) is [{ value: string, label: string }].
Upgrading to namespaces (0.4.0)
0.4.0 adds a namespace column to Profile and ProfileField, a nullable semanticRole column to ProfileField, and replaces the global unique on ProfileField.key with @@unique([namespace, key]). This is a breaking database migration for existing consumers.
- Paste the updated snippet from
src/schema/profile.prismainto your schema. - Apply the migration. Either run
pnpm prisma migrate devto let Prisma generate it for your exact schema (recommended), or apply the reference SQL shipped atsrc/schema/migrations/0001_add_namespace.sql(MySQL/MariaDB; verify your index names withSHOW INDEXfirst). - Deploy ordering matters. The 0.4.0 adapter resolves fields via the composite unique; code running ≤ 0.3.0 resolves by
keyalone. Apply the migration together with the package upgrade and redeploy — never against a database still served by 0.3.0 code.
Existing rows are backfilled to namespace "default", and code that passes no namespace continues to behave exactly as before.
Ports
The package never imports your auth library, your Prisma client, or your storage SDK. Instead it accepts five ports:
AuthPort<S>
interface AuthPort<S> {
requireAuth(req: Request): Promise<S>;
isAdmin(scope: S): boolean;
isSuperAdmin?(scope: S): boolean;
userId(scope: S): string;
}requireAuthreturns your typed scope or throws (caller-defined).isAdmingates write endpoints.isSuperAdmingates field-schema endpoints.userIdreturns the actor for hooks + audit.
StoragePort
Same shape as @firstlovecenter/milestone-grid's StoragePort so you can share one R2 impl. Includes presign / streamGet / delete / HMAC stamp + verify / optional max-size + content-type allow-list.
PersistencePort
Created via createPrismaPersistence(prismaClient). If you don't use Prisma, you can implement the interface yourself against any data store.
ProfileHooks<S> (optional)
interface ProfileHooks<S> {
onProfileCreated?(ctx: { profile, userId, scope }): Promise<void>;
onProfileUpdated?(ctx: { before, after, userId, scope }): Promise<void>;
onProfileArchived?(ctx: { profile, userId, reason, scope }): Promise<void>;
onProfileRestored?(ctx: { profile, userId, scope }): Promise<void>;
onFieldCreated?(ctx: { field, userId, scope }): Promise<void>;
onFieldUpdated?(ctx: { before, after, userId, scope }): Promise<void>;
onFieldArchived?(ctx: { field, userId, scope }): Promise<void>;
}Use hooks to write your own audit log, to maintain a denormalised projection (e.g., a Missionary table linked 1:1 to Profile), or to fan out events to a queue.
Theming
The package ships two CSS files:
styles.css— structural; loads BEFORE Tailwind.theme.css— overridable CSS variables; loads AFTER Tailwind.
Override --profile-* variables in your own :root / .dark block:
:root {
--profile-brand: var(--primary);
--profile-surface: var(--card);
--profile-muted: var(--muted-foreground);
--profile-border: var(--border);
--profile-accent: var(--accent);
--profile-destructive: var(--destructive);
--profile-radius: 0.5rem;
}The package's components default to shadcn-compatible values, so the most common case (a shadcn-themed host) needs zero overrides.
Field-schema migration guarantees
- Editing a
ProfileField(rename label, change required, change options) does NOT rewrite existingProfile.customFields. Existing rows preserve their values under the originalkey. - Archiving a
ProfileFieldhides it from forms and lists; values stay inProfile.customFieldsand are returned by API reads. - Restoring a
ProfileFieldbrings it back, no row changes needed. - Hard-deleting a
ProfileFieldis NOT supported. Archive only. keyis immutable. To rename a key, archive the old field and create a new one.
Every field mutation writes a ProfileFieldChange row for the audit trail.
Reuse in another app
To wire @firstlovecenter/flc-profile into a brand-new Next.js + Prisma app (e.g., an HR system):
- Paste the Prisma snippet into
schema.prisma. Runprisma migrate dev. - Implement your own
AuthPort(NextAuth, Lucia, custom — your choice). - Implement / borrow a
StoragePort(the package's contract matches milestone-grid's). - Call
configureProfile({ ... })and mount the routes. - Seed your initial
ProfileFieldrows for your domain (employeeNumber, department, etc.). - Import
<ProfileList>,<ProfileForm>,<ProfileFieldBuilder>and mount them in your app's pages.
Zero code in this package changes between hosts.
See docs/host-integration.md for the full integration walkthrough using flc-missions as a worked example.
API reference
Routes are mounted under routeBasePath (default /api/profile).
| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| GET | /profiles | isAdmin | List profiles (paginated). Query: archived, q, cursor, limit. |
| POST | /profiles | isAdmin | Create profile. Body: { customFields }. |
| GET | /profiles/[id] | isAdmin | Get a profile. |
| PATCH | /profiles/[id] | isAdmin | Update customFields. |
| DELETE | /profiles/[id] | isAdmin | Archive. Body: { reason? }. |
| POST | /profiles/[id]/restore | isAdmin | Restore. |
| POST | /profiles/[id]/photo | isAdmin | Presigned PUT for profile photo. |
| GET | /fields | isAdmin | List field defs. Query: archived. |
| POST | /fields | isSuperAdmin | Create field. |
| PATCH | /fields/[id] | isSuperAdmin | Update field (label, type, options, etc.). |
| DELETE | /fields/[id] | isSuperAdmin | Archive field (data preserved). |
| POST | /fields/reorder | isSuperAdmin | Bulk reorder [{ id, sortOrder }]. |
Errors return the standard envelope:
{
"error": {
"code": "VALIDATION_ERROR" | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "INTERNAL",
"message": "Human-readable summary",
"details": [{ "path": ["..."], "message": "..." }],
"requestId": "req_..."
}
}Type reference
All exports are typed; see src/server/types.ts for canonical definitions.
import type {
ProfileFieldType,
FieldOption,
ProfileDTO,
ProfileFieldDTO,
ProfileFieldChangeDTO,
AuthPort,
StoragePort,
PersistencePort,
PrismaPersistenceOptions,
ProfileHooks,
ConfigureProfileOptions,
ConfiguredProfile,
ProfileRoutes,
MemoryPersistence,
MemoryStore,
} from "@firstlovecenter/flc-profile/server";
// Runtime helpers: configureProfile, createPrismaPersistence,
// createMemoryPersistence, createMemoryStore, buildZodSchema, composePrimaryLabelUI exports:
import {
ProfileList,
ProfileView,
ProfileForm,
ProfileFieldBuilder,
ProfileCard,
ArchivedProfiles,
createProfileClient,
ProfileClientError,
} from "@firstlovecenter/flc-profile/ui";
import type {
ProfileClient,
CreateProfileClientOptions,
ProfileClientErrorDetail,
ProfileListProps,
ProfileViewProps,
ProfileFormProps,
ProfileFieldBuilderProps,
ProfileCardProps,
ArchivedProfilesProps,
} from "@firstlovecenter/flc-profile/ui";Development
pnpm install
pnpm build # tsup → dist/
pnpm typecheck
pnpm test
pnpm dev # tsup --watchTests live in src/**/*.test.ts (Vitest).
License
UNLICENSED — internal First Love Centre code.
