Added ESA functionality

This commit is contained in:
Dmitri 2026-06-22 19:03:37 +02:00
parent 9531ea34f2
commit b1b08fea70
44 changed files with 1696 additions and 163 deletions

View File

@ -27,6 +27,7 @@ DTO fields: `id`, `content_type`, `payload`, `updatedAt`.
## Access Rules ## Access Rules
- `GET /api/content-catalog/read/:contentType` requires an authenticated user and returns only records where `active = true`. - `GET /api/content-catalog/read/:contentType` requires an authenticated user and returns only records where `active = true`.
- All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited. - All `/api/content-catalog` management endpoints require `MANAGE_CONTENT_CATALOG`. `custom_permissions` can grant it and `custom_permissions_filter` can remove it for non-global users. Tenant/content-type scoping still constrains which row is edited.
- `esa-funding-content` management requires `MANAGE_ESA_FUNDING_CONTENT` and campus effective scope. Parent users manage campus ESA content by drilling into a campus. Editing ESA content also stores the updated section name, bumps the linked `policy_documents` ESA acknowledgment document version, and makes staff re-acknowledge.
- `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library. - `classroom-strategies` is an organization-scoped catalog. Management requires `MANAGE_CONTENT_CATALOG` and an effective organization scope; school, campus, and class drill-down scopes can read it but cannot update the organization strategy library.
- `safety-qbs-quiz` is also organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` manage the weekly quiz and key reminders once for the organization; school, campus, and class users only read and complete that organization-owned quiz. - `safety-qbs-quiz` is also organization-scoped. Organization users with `MANAGE_CONTENT_CATALOG` manage the weekly quiz and key reminders once for the organization; school, campus, and class users only read and complete that organization-owned quiz.
- `emotional-intelligence-assessment-questions` and `emotional-intelligence-personality-quiz` are organization-scoped catalogs. Organization users with `MANAGE_CONTENT_CATALOG` manage the active quiz content once for the organization; descendant scopes read and complete that organization-owned content. - `emotional-intelligence-assessment-questions` and `emotional-intelligence-personality-quiz` are organization-scoped catalogs. Organization users with `MANAGE_CONTENT_CATALOG` manage the active quiz content once for the organization; descendant scopes read and complete that organization-owned content.
@ -37,6 +38,7 @@ Content records can be tenant-scoped through nullable `organizationId`, `schoolI
- Per-tenant types use the caller's own tenant (`getOwnTenant`) and exact owner matching. Dashboard content (`dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, and related dashboard assets) is seeded at organization, school, and campus scope so leadership dashboards work at every supported drill-down level. - Per-tenant types use the caller's own tenant (`getOwnTenant`) and exact owner matching. Dashboard content (`dashboard-encouraging-quotes`, `dashboard-compliance-items`, `dashboard-sign-of-week`, and related dashboard assets) is seeded at organization, school, and campus scope so leadership dashboards work at every supported drill-down level.
- School-scoped types read the caller's resolved school row. - School-scoped types read the caller's resolved school row.
- Campus-scoped types read the caller's resolved campus row; class-scoped and external users with a campus read the campus row.
- Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows. - Org-scoped types read the caller's organization row. `classroom-strategies` is org-scoped: every organization gets one preset editable strategy library, while school, campus, and classroom views read that organization library instead of owning duplicate rows.
- `safety-qbs-quiz` is org-scoped for the same reason: there is one weekly QBS quiz payload per organization, and descendant scopes read the organization payload. - `safety-qbs-quiz` is org-scoped for the same reason: there is one weekly QBS quiz payload per organization, and descendant scopes read the organization payload.
- Emotional Intelligence self-assessment and Personality Type quiz payloads are org-scoped for the same reason: each organization owns its active quiz versions and descendant scopes read the organization payloads. - Emotional Intelligence self-assessment and Personality Type quiz payloads are org-scoped for the same reason: each organization owns its active quiz versions and descendant scopes read the organization payloads.
@ -65,7 +67,7 @@ The seeder (`20260608103000-content-catalog.ts`) loads the following `content_ty
The migration `20260614090000-drop-global-content-catalog-rows.ts` removes the former global static rows: `personality-*` and `classroom-timer-*`. The migration `20260616170000-backfill-dashboard-content-scopes.ts` backfills per-tenant dashboard catalog defaults for existing organization, school, and campus records. The migration `20260618131000-backfill-emotional-intelligence-quiz-content.ts` backfills missing Emotional Intelligence and Personality Type quiz content for existing organizations. The migration `20260614090000-drop-global-content-catalog-rows.ts` removes the former global static rows: `personality-*` and `classroom-timer-*`. The migration `20260616170000-backfill-dashboard-content-scopes.ts` backfills per-tenant dashboard catalog defaults for existing organization, school, and campus records. The migration `20260618131000-backfill-emotional-intelligence-quiz-content.ts` backfills missing Emotional Intelligence and Personality Type quiz content for existing organizations.
New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, `safety-qbs-quiz`, Emotional Intelligence self-assessment questions, the Personality Type quiz, and Zones of Regulation content; school creation presets school-scoped content; campus creation presets only per-tenant campus content. This keeps shared libraries and editable organization-wide content owned at the organization level. New tenant creation uses `ContentCatalogSeedService.seedDefaultContentForTenant`: org creation presets org-scoped content such as `classroom-strategies`, `safety-qbs-quiz`, Emotional Intelligence self-assessment questions, the Personality Type quiz, and Zones of Regulation content; school creation presets school-scoped content; campus creation presets per-tenant campus content plus the campus-scoped ESA funding content and its policy-acknowledgment document. This keeps shared libraries and editable organization-wide content owned at the organization level while ESA stays campus-owned.
### Content authoring rules ### Content authoring rules
- Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants. - Add editable or tenant-scoped production content records to backend seed payloads, not frontend constants.

View File

@ -22,6 +22,11 @@ entity it replaced has been removed):
are gated by effective policy-document permissions. Acknowledgment is **persisted** via `policy_acknowledgments` are gated by effective policy-document permissions. Acknowledgment is **persisted** via `policy_acknowledgments`
(`usePolicyAcknowledgments` / `useAcknowledgePolicy`), replacing the former (`usePolicyAcknowledgments` / `useAcknowledgePolicy`), replacing the former
local-state set. local-state set.
- **ESA Funding Information** uses the same handbook-policy document store for
staff acknowledgment tracking. Each campus owns one active handbook document
tagged `ESA Funding`; ESA content edits bump that document version and append
the updated section name to the document body so header notifications can
point staff back to `/esa-funding`.
- **Safety Protocols** (`business/safety-protocols`) consumes - **Safety Protocols** (`business/safety-protocols`) consumes
`category = safety_protocol`, rendering author-filled `steps` + autism `category = safety_protocol`, rendering author-filled `steps` + autism
considerations with a static per-`tag` card icon (fire/shield/heart). It shares considerations with a static per-`tag` card icon (fire/shield/heart). It shares
@ -55,7 +60,8 @@ entity it replaced has been removed):
(`safety_protocol` | `handbook_policy` — selects the page), `tag` (nullable (`safety_protocol` | `handbook_policy` — selects the page), `tag` (nullable
finer **sub-category**; the Handbook page maps its finer **sub-category**; the Handbook page maps its
Operations/Behavior/Safety/Communication/Legal categorisation onto it, and the Operations/Behavior/Safety/Communication/Legal categorisation onto it, and the
Safety page uses it to pick the static card icon), `author` (display name of Safety page uses it to pick the static card icon; ESA Funding uses
`tag = "ESA Funding"` for its campus acknowledgment document), `author` (display name of
the **creating user**, set server-side at creation and not changed on update), the **creating user**, set server-side at creation and not changed on update),
`steps` + `autism_considerations` (JSONB string arrays — **author-filled `steps` + `autism_considerations` (JSONB string arrays — **author-filled
structured content** for safety protocols; null for handbook policies), structured content** for safety protocols; null for handbook policies),
@ -124,12 +130,16 @@ visible active document version is acknowledged.
re-acknowledgment bump, and `formatPersonName` (author rendering). re-acknowledgment bump, and `formatPersonName` (author rendering).
- **Backend service** (`backend/src/services/policy_acknowledgments.test.ts`): - **Backend service** (`backend/src/services/policy_acknowledgments.test.ts`):
acknowledgment listing plus the drilled-child no-op rule for parent users. acknowledgment listing plus the drilled-child no-op rule for parent users.
- **Content catalog service** (`backend/src/services/content_catalog.test.ts`):
ESA funding content updates bump the linked policy-document version and store
the updated section name for staff notifications.
- **Frontend unit** (`vitest`): `business/policies/mappers.test.ts` (handbook; - **Frontend unit** (`vitest`): `business/policies/mappers.test.ts` (handbook;
tag↔category, author), `business/safety-protocols/mappers.test.ts` (steps + tag↔category, author), `business/safety-protocols/mappers.test.ts` (steps +
autism considerations), `business/safety-protocols/selectors.test.ts` autism considerations), `business/safety-protocols/selectors.test.ts`
(management grant + draft validation for the authoring form), (management grant + draft validation for the authoring form),
`business/director-dashboard/selectors.test.ts` (dashboard acknowledgment rows `business/director-dashboard/selectors.test.ts` (dashboard acknowledgment rows
and risk areas), and `business/profile/selectors.test.ts` (current-version and risk areas), `business/top-bar/selectors.test.ts` (ESA updated-section
notifications), and `business/profile/selectors.test.ts` (current-version
profile checklist rows, missing-first sorting, and old-version acknowledgment profile checklist rows, missing-first sorting, and old-version acknowledgment
exclusion). exclusion).
- **Seeded e2e** (`frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts`, - **Seeded e2e** (`frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts`,

View File

@ -0,0 +1,124 @@
import { v4 as uuid } from 'uuid';
import { QueryTypes, type QueryInterface } from 'sequelize';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
import { POLICY_DOCUMENT_CATEGORIES } from '@/shared/constants/policy-documents';
import { CONTENT_CATALOG_SEED_PAYLOADS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads';
const ESA_CONTENT_TYPE = 'esa-funding-content';
const ESA_POLICY_TAG = 'ESA Funding';
const ESA_POLICY_TITLE = 'ESA Funding Information';
function isIdRow(value: unknown): value is { id: string } {
return (
value !== null
&& typeof value === 'object'
&& 'id' in value
&& typeof value.id === 'string'
);
}
function firstId(value: unknown): string | null {
return Array.isArray(value) ? value.find(isIdRow)?.id ?? null : null;
}
export default {
up: async (queryInterface: QueryInterface) => {
const now = new Date();
const [permissionRows] = await queryInterface.sequelize.query(
'SELECT "id" FROM "permissions" WHERE "name" = :name LIMIT 1',
{ replacements: { name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT } },
);
let permissionId = firstId(permissionRows);
if (!permissionId) {
permissionId = uuid();
await queryInterface.bulkInsert('permissions', [{
id: permissionId,
name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT,
createdAt: now,
updatedAt: now,
}]);
}
const campuses = await queryInterface.sequelize.query<{
id: string;
organizationId: string;
}>(
`SELECT "id", "organizationId" FROM "campuses" WHERE "deletedAt" IS NULL`,
{ type: QueryTypes.SELECT },
);
const contentRows = campuses.map((campus) => ({
id: uuid(),
content_type: ESA_CONTENT_TYPE,
payload: JSON.stringify(CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent),
active: true,
importHash: `content-catalog-${ESA_CONTENT_TYPE}-${campus.id}`,
organizationId: campus.organizationId,
schoolId: null,
campusId: campus.id,
classId: null,
createdAt: now,
updatedAt: now,
}));
if (contentRows.length > 0) {
const existingContentRows = await queryInterface.sequelize.query<{ importHash: string }>(
`SELECT "importHash" FROM content_catalog WHERE "importHash" IN (:importHashes)`,
{
replacements: { importHashes: contentRows.map((row) => row.importHash) },
type: QueryTypes.SELECT,
},
);
const existingContentHashes = new Set(existingContentRows.map((row) => row.importHash));
const missingContentRows = contentRows.filter((row) => !existingContentHashes.has(row.importHash));
if (missingContentRows.length > 0) {
await queryInterface.bulkInsert('content_catalog', missingContentRows);
}
}
const policyRows = campuses.map((campus) => ({
id: uuid(),
title: ESA_POLICY_TITLE,
body: 'Staff must review the current ESA Funding Information page and acknowledge understanding of the campus guidance.',
category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY,
tag: ESA_POLICY_TAG,
author: null,
steps: null,
autism_considerations: null,
version: 1,
active: true,
importHash: `policy-doc-esa-funding-${campus.id}`,
organizationId: campus.organizationId,
schoolId: null,
campusId: campus.id,
classId: null,
createdById: null,
updatedById: null,
createdAt: now,
updatedAt: now,
}));
if (policyRows.length > 0) {
const existingPolicyRows = await queryInterface.sequelize.query<{ importHash: string }>(
`SELECT "importHash" FROM policy_documents WHERE "importHash" IN (:importHashes)`,
{
replacements: { importHashes: policyRows.map((row) => row.importHash) },
type: QueryTypes.SELECT,
},
);
const existingPolicyHashes = new Set(existingPolicyRows.map((row) => row.importHash));
const missingPolicyRows = policyRows.filter((row) => !existingPolicyHashes.has(row.importHash));
if (missingPolicyRows.length > 0) {
await queryInterface.bulkInsert('policy_documents', missingPolicyRows);
}
}
},
down: async () => {
// Keep permission and seeded ESA rows on rollback; campus content may be edited.
},
};

View File

@ -0,0 +1,86 @@
import { v4 as uuid } from 'uuid';
import type { QueryInterface } from 'sequelize';
import { ROLE_NAMES } from '@/shared/constants/roles';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
const ESA_MANAGEMENT_ROLES = [
ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR,
ROLE_NAMES.OFFICE_MANAGER,
] as const;
function isIdRow(value: unknown): value is { id: string } {
return (
value !== null
&& typeof value === 'object'
&& 'id' in value
&& typeof value.id === 'string'
);
}
function idRows(value: unknown): readonly { id: string }[] {
return Array.isArray(value) ? value.filter(isIdRow) : [];
}
function firstId(value: unknown): string | null {
return idRows(value)[0]?.id ?? null;
}
export default {
up: async (queryInterface: QueryInterface) => {
const now = new Date();
const [permissionRows] = await queryInterface.sequelize.query(
'SELECT "id" FROM "permissions" WHERE "name" = :name LIMIT 1',
{ replacements: { name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT } },
);
let permissionId = firstId(permissionRows);
if (!permissionId) {
permissionId = uuid();
await queryInterface.bulkInsert('permissions', [{
id: permissionId,
name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT,
createdAt: now,
updatedAt: now,
}]);
}
const [roleRows] = await queryInterface.sequelize.query(
'SELECT "id" FROM "roles" WHERE "name" IN (:names)',
{ replacements: { names: ESA_MANAGEMENT_ROLES } },
);
const roleIds = idRows(roleRows).map((row) => row.id);
if (roleIds.length === 0) {
return;
}
const [existingRows] = await queryInterface.sequelize.query(
`SELECT "roles_permissionsId" AS "id"
FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" IN (:roleIds)
AND "permissionId" = :permissionId`,
{ replacements: { roleIds, permissionId } },
);
const existingRoleIds = new Set(idRows(existingRows).map((row) => row.id));
const missingRows = roleIds
.filter((roleId) => !existingRoleIds.has(roleId))
.map((roleId) => ({
createdAt: now,
updatedAt: now,
roles_permissionsId: roleId,
permissionId,
}));
if (missingRows.length > 0) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', missingRows);
}
},
down: async () => {
// Keep permission grants on rollback; permissions may be assigned manually.
},
};

View File

@ -107,6 +107,7 @@ export const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly strin
...MODULE_READ_PARENT_COMM, ...MODULE_READ_PARENT_COMM,
...MODULE_READ_EXTERNAL, ...MODULE_READ_EXTERNAL,
...MODULE_ACTIONS, ...MODULE_ACTIONS,
'MANAGE_ESA_FUNDING_CONTENT',
'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES', 'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES',
], ],
[ROLE_NAMES.TEACHER]: [ [ROLE_NAMES.TEACHER]: [

View File

@ -17,6 +17,7 @@ import {
} from '@/shared/constants/seed-fixtures'; } from '@/shared/constants/seed-fixtures';
import { import {
PER_TENANT_CONTENT_TYPES, PER_TENANT_CONTENT_TYPES,
CAMPUS_SCOPED_CONTENT_TYPES,
SCHOOL_SCOPED_CONTENT_TYPES, SCHOOL_SCOPED_CONTENT_TYPES,
ORG_SCOPED_CONTENT_TYPES, ORG_SCOPED_CONTENT_TYPES,
} from '@/shared/constants/content-catalog'; } from '@/shared/constants/content-catalog';
@ -54,6 +55,16 @@ function seedTenants(contentType: string): Array<{
{ organizationId: SEED_ORGANIZATION_2_ID, schoolId: null, campusId: SEED_SECONDARY_CAMPUS_ID }, { organizationId: SEED_ORGANIZATION_2_ID, schoolId: null, campusId: SEED_SECONDARY_CAMPUS_ID },
]; ];
} }
if (CAMPUS_SCOPED_CONTENT_TYPES.has(contentType)) {
return [
...demoCampusIds.map((campusId) => ({
organizationId: SEED_ORGANIZATION_ID,
schoolId: null,
campusId,
})),
{ organizationId: SEED_ORGANIZATION_2_ID, schoolId: null, campusId: SEED_SECONDARY_CAMPUS_ID },
];
}
if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) { if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) {
return [ return [
{ organizationId: SEED_ORGANIZATION_ID, schoolId: SEED_SCHOOL_ID, campusId: null }, { organizationId: SEED_ORGANIZATION_ID, schoolId: SEED_SCHOOL_ID, campusId: null },

View File

@ -864,50 +864,83 @@ const CONTENT_CATALOG_SEED_PAYLOADS = Object.freeze({
{ label: 'Growth Trend', value: '+8%', color: 'text-pink-400', bar: 'bg-pink-500', width: '65%' }, { label: 'Growth Trend', value: '+8%', color: 'text-pink-400', bar: 'bg-pink-500', width: '65%' },
], ],
esaFundingContent: { esaFundingContent: {
approvedUses: [
{ iconId: 'school', title: 'Private School Tuition', description: 'Full or partial tuition at approved private schools, including autism-focused programs', color: 'from-violet-500 to-violet-600' },
{ iconId: 'heart', title: 'Specialized Therapies', description: 'Speech therapy, occupational therapy, ABA, physical therapy, and counseling', color: 'from-pink-500 to-rose-600' },
{ iconId: 'book', title: 'Tutoring Services', description: 'One-on-one or small group tutoring from approved educational providers', color: 'from-blue-500 to-blue-600' },
{ iconId: 'puzzle', title: 'Curriculum & Materials', description: 'Textbooks, workbooks, educational software, and approved learning materials', color: 'from-amber-500 to-orange-600' },
{ iconId: 'graduation', title: 'Online Learning', description: 'Approved online courses, virtual tutoring, and digital learning platforms', color: 'from-emerald-500 to-green-600' },
{ iconId: 'users', title: 'Educational Services', description: 'Social skills groups, life skills training, vocational preparation, and more', color: 'from-cyan-500 to-teal-600' },
],
keyPoints: [
{ iconId: 'arrow', label: 'Education money that follows the child' },
{ iconId: 'school', label: 'Instead of staying with one school' },
{ iconId: 'users', label: 'Parents choose the best fit for their child' },
{ iconId: 'check', label: 'Covers tuition, therapies, tutoring & more' },
{ iconId: 'star', label: 'More flexibility and control for families' },
{ iconId: 'heart', label: 'Especially impactful for students with disabilities' },
],
stateChecklist: [
'Whether your state offers an ESA or similar school choice program',
'What the specific eligibility requirements are in your state',
'What expenses and services ESA funds can be applied toward in your state',
'The application process and deadlines for your state\'s program',
'Any reporting or documentation requirements unique to your state',
],
schoolImpactItems: [ schoolImpactItems: [
'ESA makes specialized autism-focused programs accessible to more families', { id: 'impact-access', text: 'ESA makes specialized autism-focused programs accessible to more families' },
'Families can use ESA funds to cover tuition at approved schools', { id: 'impact-tuition', text: 'Families can use ESA funds to cover tuition at approved schools' },
'Therapeutic services such as speech, OT, and ABA may be ESA-eligible', { id: 'impact-therapies', text: 'Therapeutic services such as speech, OT, and ABA may be ESA-eligible' },
'It can remove financial barriers for families seeking the best fit for their child', { id: 'impact-barriers', text: 'It can remove financial barriers for families seeking the best fit for their child' },
'More enrolled students can support program expansion and staffing', { id: 'impact-growth', text: 'More enrolled students can support program expansion and staffing' },
], ],
staffRoleItems: [ staffRoleItems: [
{ title: 'Be Informed', description: 'Understand the basics of ESA so you can answer general questions from parents' }, { id: 'staff-informed', title: 'Be Informed', description: 'Understand the basics of ESA so you can answer general questions from parents' },
{ title: 'Direct to Office', description: 'For detailed ESA questions, guide families to the office team for personalized support' }, { id: 'staff-office', title: 'Direct to Office', description: 'For detailed ESA questions, guide families to the office team for personalized support' },
{ title: 'Document Accurately', description: 'Ensure student services and progress are documented properly for ESA reporting' }, { id: 'staff-document', title: 'Document Accurately', description: 'Ensure student services and progress are documented properly for ESA reporting' },
{ title: 'Stay Focused', description: 'Continue providing excellent, individualized education — ESA is about access, not changing what staff do' }, { id: 'staff-focused', title: 'Stay Focused', description: 'Continue providing excellent, individualized education — ESA is about access, not changing what staff do' },
],
faqs: [
{
id: 'faq-what-is-esa',
audience: 'all',
question: 'What is an ESA (Empowerment Scholarship Account)?',
answer: 'An ESA is a state-funded account that provides education dollars directly to families. Instead of funding going only to a single public school, ESA money follows the child, allowing parents to choose the learning environment and services that best fit their child\'s unique needs.',
},
{
id: 'faq-eligible',
audience: 'all',
question: 'Who is eligible for ESA funding?',
answer: 'Eligibility varies by state, but ESA programs typically prioritize students with disabilities including autism, students from low-income families, students in underperforming schools, foster children, and children of active-duty military. In many states, all K-12 students are now eligible.',
},
{
id: 'faq-uses',
audience: 'all',
question: 'What can ESA funds be used for?',
answer: 'ESA funds can be used for approved educational expenses including private school tuition, specialized therapies, tutoring services, curriculum and textbooks, educational technology, online learning programs, and other approved educational services.',
},
{
id: 'faq-school-impact',
audience: 'all',
question: 'How does ESA funding affect our school?',
answer: 'ESA funding is a positive opportunity for our school community. Families who choose our programs can use ESA funds to pay for tuition and specialized services we offer. This means more families can access the autism-focused education and therapies we provide, regardless of their financial situation.',
},
{
id: 'faq-amount',
audience: 'all',
question: 'How much funding does each student receive?',
answer: 'The amount varies by state and student need. ESA amounts often range from several thousand dollars per year for general education students to higher amounts for students with disabilities who require specialized services and therapies.',
},
{
id: 'faq-apply',
audience: 'all',
question: 'How do families apply for ESA funding?',
answer: 'Families apply through their state ESA program, often through the Department of Education website. The process typically involves submitting an application, providing proof of eligibility, receiving approval, and directing funds to chosen educational providers.',
},
{
id: 'faq-therapies',
audience: 'all',
question: 'Can ESA funds be used for therapies at our school?',
answer: 'Yes. ESA funds can cover many specialized services, including speech therapy, occupational therapy, behavioral therapy, social skills groups, and other therapeutic interventions when those services are approved by the state program.',
},
{
id: 'faq-staff-role',
audience: 'staff',
question: 'What is our role as staff in the ESA process?',
answer: 'Staff should understand the basics, direct families to the office for detailed guidance, document student services accurately for ESA reporting, and continue providing individualized education regardless of how services are funded.',
},
],
parentConversationReferences: [
{
id: 'parent-reference-overview',
question: 'If a parent asks about ESA, here is a simple way to explain it:',
answer: '"ESA stands for Empowerment Scholarship Account. It is state funding set aside for your child\'s education. Instead of the money only going to one school, it follows your child, so you can use it to pay for the school, therapies, tutoring, and services that work best for them. Our office team can walk you through the application process and help you understand what is covered."',
},
], ],
parentConversationScript: '"ESA stands for Empowerment Scholarship Account. It is state funding set aside for your child\'s education. Instead of the money only going to one school, it follows your child, so you can use it to pay for the school, therapies, tutoring, and services that work best for them. Our office team can walk you through the application process and help you understand what is covered."',
resources: [ resources: [
{ title: 'AZ ESA Program', description: 'Arizona Empowerment Scholarship Account official page', url: '#' }, { id: 'resource-az-program', title: 'AZ ESA Program', description: 'Arizona Empowerment Scholarship Account official page', url: '#' },
{ title: 'ESA Eligible Expenses', description: 'Full list of approved uses for ESA funds', url: '#' }, { id: 'resource-expenses', title: 'ESA Eligible Expenses', description: 'Full list of approved uses for ESA funds', url: '#' },
{ title: 'Family Application Guide', description: 'Step-by-step guide for families applying', url: '#' }, { id: 'resource-family-guide', title: 'Family Application Guide', description: 'Step-by-step guide for families applying', url: '#' },
{ title: 'Provider Registration', description: 'How schools register as ESA providers', url: '#' }, { id: 'resource-provider-registration', title: 'Provider Registration', description: 'How schools register as ESA providers', url: '#' },
{ title: 'ESA FAQ (State)', description: 'Official state FAQ for families and schools', url: '#' }, { id: 'resource-state-faq', title: 'ESA FAQ (State)', description: 'Official state FAQ for families and schools', url: '#' },
{ title: 'Internal ESA Procedures', description: 'School ESA documentation process', url: '#' }, { id: 'resource-internal-procedures', title: 'Internal ESA Procedures', description: 'School ESA documentation process', url: '#' },
], ],
}, },
safetyProtocols: [ safetyProtocols: [

View File

@ -129,6 +129,21 @@ describe('user-role seed permission contract', () => {
]); ]);
}); });
test('ESA funding management grants match campus and parent-scope managers', () => {
const esaManagers = Object.values(ROLE_NAMES).filter((role) =>
granted(role).includes(FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT),
);
assert.deepEqual(esaManagers, [
ROLE_NAMES.SYSTEM_ADMIN,
ROLE_NAMES.OWNER,
ROLE_NAMES.SUPERINTENDENT,
ROLE_NAMES.PRINCIPAL,
ROLE_NAMES.DIRECTOR,
ROLE_NAMES.OFFICE_MANAGER,
]);
});
test('seeded permission grants are unique per role', () => { test('seeded permission grants are unique per role', () => {
for (const role of Object.values(ROLE_NAMES)) { for (const role of Object.values(ROLE_NAMES)) {
const permissions = granted(role); const permissions = granted(role);

View File

@ -36,6 +36,19 @@ function organizationContentManager() {
}); });
} }
function campusEsaManager() {
return createGlobalAccessUser({
app_role: {
name: ROLE_NAMES.DIRECTOR,
scope: ROLE_SCOPES.CAMPUS,
globalAccess: false,
permissions: [{ name: FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT }],
},
organizationId: 'org-1',
campusId: 'campus-1',
});
}
afterEach(() => { afterEach(() => {
mock.restoreAll(); mock.restoreAll();
}); });
@ -374,4 +387,59 @@ describe('ContentCatalogService tenant scoping', () => {
assert.equal(result.id, 'catalog-new'); assert.equal(result.id, 'catalog-new');
assert.deepEqual(commits, ['commit']); assert.deepEqual(commits, ['commit']);
}); });
test('ESA funding updates bump acknowledgment policy version with section summary', async () => {
const commits: string[] = [];
const catalogUpdates: unknown[] = [];
const policyUpdates: unknown[] = [];
mock.method(db.sequelize, 'transaction', (async () => ({
commit: async () => { commits.push('commit'); },
rollback: async () => { commits.push('rollback'); },
})) as typeof db.sequelize.transaction);
mock.method(db.content_catalog, 'findOne', (async () => ({
update: async (data: unknown) => {
catalogUpdates.push(data);
return {
get: () => ({
id: 'esa-content-1',
content_type: 'esa-funding-content',
payload: { resources: [] },
updatedAt: new Date('2026-06-22T00:00:00Z'),
}),
};
},
get: () => ({
id: 'esa-content-1',
content_type: 'esa-funding-content',
payload: { resources: [] },
updatedAt: new Date('2026-06-22T00:00:00Z'),
}),
})) as unknown as typeof db.content_catalog.findOne);
mock.method(db.policy_documents, 'findOne', (async () => ({
version: 4,
update: async (data: unknown) => {
policyUpdates.push(data);
return null;
},
})) as unknown as typeof db.policy_documents.findOne);
await ContentCatalogService.update(
'esa-funding-content',
{
payload: { resources: [] },
changeSummary: 'Helpful Resources',
},
campusEsaManager(),
);
assert.deepEqual(catalogUpdates, [{ payload: { resources: [] }, active: true }]);
assert.deepEqual(policyUpdates, [{
body: 'Staff must review the current ESA Funding Information page and acknowledge understanding of the campus guidance.\n\nUpdated section: Helpful Resources',
version: 5,
}]);
assert.deepEqual(commits, ['commit']);
});
}); });

View File

@ -1,4 +1,5 @@
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import type { Transaction } from 'sequelize';
import db from '@/db/models'; import db from '@/db/models';
import { withTransaction } from '@/db/with-transaction'; import { withTransaction } from '@/db/with-transaction';
import { resolvePagination } from '@/shared/constants/pagination'; import { resolvePagination } from '@/shared/constants/pagination';
@ -8,6 +9,7 @@ import {
getRoleScope, getRoleScope,
getOwnTenant, getOwnTenant,
getOrganizationId, getOrganizationId,
getCampusId,
getSchoolId, getSchoolId,
hasFeaturePermission, hasFeaturePermission,
tenantExactWhere, tenantExactWhere,
@ -15,17 +17,20 @@ import {
} from '@/services/shared/access'; } from '@/services/shared/access';
import { import {
CLASSROOM_SUPPORT_CONTENT_TYPE, CLASSROOM_SUPPORT_CONTENT_TYPE,
ESA_CONTENT_TYPE,
EI_ASSESSMENT_CONTENT_TYPE, EI_ASSESSMENT_CONTENT_TYPE,
PERSONALITY_QUIZ_CONTENT_TYPE, PERSONALITY_QUIZ_CONTENT_TYPE,
SIGN_LANGUAGE_ITEMS_CONTENT_TYPE, SIGN_LANGUAGE_ITEMS_CONTENT_TYPE,
SAFETY_QUIZ_CONTENT_TYPE, SAFETY_QUIZ_CONTENT_TYPE,
PER_TENANT_CONTENT_TYPES, PER_TENANT_CONTENT_TYPES,
CAMPUS_SCOPED_CONTENT_TYPES,
SCHOOL_SCOPED_CONTENT_TYPES, SCHOOL_SCOPED_CONTENT_TYPES,
ORG_SCOPED_CONTENT_TYPES, ORG_SCOPED_CONTENT_TYPES,
TENANT_SCOPED_CONTENT_TYPES, TENANT_SCOPED_CONTENT_TYPES,
} from '@/shared/constants/content-catalog'; } from '@/shared/constants/content-catalog';
import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions';
import { ROLE_SCOPES } from '@/shared/constants/roles'; import { ROLE_SCOPES } from '@/shared/constants/roles';
import { POLICY_DOCUMENT_CATEGORIES } from '@/shared/constants/policy-documents';
import type { ContentCatalog } from '@/db/models/content_catalog'; import type { ContentCatalog } from '@/db/models/content_catalog';
import type { CurrentUser } from '@/db/api/types'; import type { CurrentUser } from '@/db/api/types';
@ -37,6 +42,16 @@ function tenantWhereFor(
if (PER_TENANT_CONTENT_TYPES.has(contentType)) { if (PER_TENANT_CONTENT_TYPES.has(contentType)) {
return tenantExactWhere(getOwnTenant(currentUser)); return tenantExactWhere(getOwnTenant(currentUser));
} }
if (CAMPUS_SCOPED_CONTENT_TYPES.has(contentType)) {
const tenant = getOwnTenant(currentUser);
const campusId = getCampusId(currentUser) ?? tenant.campusId ?? null;
return {
organizationId: tenant.organizationId,
schoolId: null,
campusId,
classId: null,
};
}
if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) { if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) {
// Everyone in the school reads their school's row (the user's school is // Everyone in the school reads their school's row (the user's school is
// resolved from their own school/campus chain). // resolved from their own school/campus chain).
@ -53,6 +68,16 @@ function tenantStampFor(contentType: string, currentUser?: CurrentUser) {
if (PER_TENANT_CONTENT_TYPES.has(contentType)) { if (PER_TENANT_CONTENT_TYPES.has(contentType)) {
return tenantStamp(getOwnTenant(currentUser)); return tenantStamp(getOwnTenant(currentUser));
} }
if (CAMPUS_SCOPED_CONTENT_TYPES.has(contentType)) {
const tenant = getOwnTenant(currentUser);
const campusId = getCampusId(currentUser) ?? tenant.campusId;
return {
organizationId: tenant.organizationId,
schoolId: null,
campusId,
classId: null,
};
}
if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) { if (SCHOOL_SCOPED_CONTENT_TYPES.has(contentType)) {
return { return {
organizationId: getOrganizationId(currentUser), organizationId: getOrganizationId(currentUser),
@ -77,6 +102,7 @@ interface ContentCatalogInput {
payload?: unknown; payload?: unknown;
active?: boolean; active?: boolean;
importHash?: string | null; importHash?: string | null;
changeSummary?: unknown;
} }
const VERSIONED_CONTENT_TYPES = new Set([ const VERSIONED_CONTENT_TYPES = new Set([
@ -85,6 +111,9 @@ const VERSIONED_CONTENT_TYPES = new Set([
PERSONALITY_QUIZ_CONTENT_TYPE, PERSONALITY_QUIZ_CONTENT_TYPE,
]); ]);
const ESA_POLICY_TAG = 'ESA Funding';
const ESA_POLICY_BODY = 'Staff must review the current ESA Funding Information page and acknowledge understanding of the campus guidance.';
function toContentCatalogDto(record: ContentCatalog) { function toContentCatalogDto(record: ContentCatalog) {
const plain = record.get({ plain: true }); const plain = record.get({ plain: true });
@ -119,6 +148,20 @@ function assertCanManageType(contentType: string, currentUser?: CurrentUser): vo
currentUser, currentUser,
FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG, FEATURE_PERMISSIONS.MANAGE_CONTENT_CATALOG,
) )
&& !(
contentType === ESA_CONTENT_TYPE
&& hasFeaturePermission(
currentUser,
FEATURE_PERMISSIONS.MANAGE_ESA_FUNDING_CONTENT,
)
)
) {
throw new ForbiddenError();
}
if (
contentType === ESA_CONTENT_TYPE
&& getRoleScope(currentUser) !== ROLE_SCOPES.CAMPUS
) { ) {
throw new ForbiddenError(); throw new ForbiddenError();
} }
@ -153,6 +196,68 @@ function assertValidPayload(payload: unknown): unknown {
return payload; return payload;
} }
function optionalChangeSummary(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed.slice(0, 120) : null;
}
function esaPolicyBody(changeSummary: string | null): string {
return changeSummary
? `${ESA_POLICY_BODY}\n\nUpdated section: ${changeSummary}`
: ESA_POLICY_BODY;
}
async function touchEsaPolicyDocument(
contentType: string,
currentUser: CurrentUser | undefined,
changeSummary: string | null,
transaction?: Transaction,
): Promise<void> {
if (contentType !== ESA_CONTENT_TYPE) {
return;
}
const where = {
category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY,
tag: ESA_POLICY_TAG,
active: true,
...tenantWhereFor(ESA_CONTENT_TYPE, currentUser),
};
const existing = await db.policy_documents.findOne({ where, transaction });
if (existing) {
await existing.update(
{
body: esaPolicyBody(changeSummary),
version: (existing.version ?? 1) + 1,
},
{ transaction },
);
return;
}
await db.policy_documents.create(
{
title: 'ESA Funding Information',
body: esaPolicyBody(changeSummary),
category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY,
tag: ESA_POLICY_TAG,
author: null,
steps: null,
autism_considerations: null,
version: 1,
active: true,
importHash: null,
...tenantStampFor(ESA_CONTENT_TYPE, currentUser),
},
{ transaction },
);
}
class ContentCatalogService { class ContentCatalogService {
static async list( static async list(
filter: { limit?: number | string; page?: number | string } = {}, filter: { limit?: number | string; page?: number | string } = {},
@ -212,6 +317,7 @@ class ContentCatalogService {
assertCanManageType(contentType, currentUser); assertCanManageType(contentType, currentUser);
const payload = assertValidPayload(data?.payload); const payload = assertValidPayload(data?.payload);
const changeSummary = optionalChangeSummary(data?.changeSummary);
const tenantWhere = tenantWhereFor(contentType, currentUser); const tenantWhere = tenantWhereFor(contentType, currentUser);
const stamp = tenantStampFor(contentType, currentUser); const stamp = tenantStampFor(contentType, currentUser);
@ -249,6 +355,8 @@ class ContentCatalogService {
); );
} }
await touchEsaPolicyDocument(contentType, currentUser, changeSummary, transaction);
return toContentCatalogDto(record); return toContentCatalogDto(record);
}); });
} }
@ -262,6 +370,7 @@ class ContentCatalogService {
assertCanManageType(normalizedContentType, currentUser); assertCanManageType(normalizedContentType, currentUser);
const payload = assertValidPayload(data?.payload); const payload = assertValidPayload(data?.payload);
const changeSummary = optionalChangeSummary(data?.changeSummary);
return withTransaction(async (transaction) => { return withTransaction(async (transaction) => {
const record = await db.content_catalog.findOne({ const record = await db.content_catalog.findOne({
@ -301,6 +410,8 @@ class ContentCatalogService {
{ transaction }, { transaction },
); );
await touchEsaPolicyDocument(normalizedContentType, currentUser, changeSummary, transaction);
return toContentCatalogDto(record); return toContentCatalogDto(record);
}); });
} }
@ -325,6 +436,7 @@ class ContentCatalogService {
await record.update({ active: false }, { transaction }); await record.update({ active: false }, { transaction });
await record.destroy({ transaction }); await record.destroy({ transaction });
await touchEsaPolicyDocument(normalizedContentType, currentUser, 'Content removed', transaction);
return true; return true;
}); });
} }

View File

@ -1,10 +1,16 @@
import { afterEach, describe, mock, test } from 'node:test'; import { afterEach, beforeEach, describe, mock, test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import db from '@/db/models'; import db from '@/db/models';
import { CONTENT_CATALOG_SEED_PAYLOADS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads'; import { CONTENT_CATALOG_SEED_PAYLOADS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads';
import { seedDefaultContentForTenant } from '@/services/content_catalog_seed'; import { seedDefaultContentForTenant } from '@/services/content_catalog_seed';
beforeEach(() => {
mock.method(db.policy_documents, 'findOne', (async () => null) as typeof db.policy_documents.findOne);
mock.method(db.policy_documents, 'create', (async (payload: Record<string, unknown>) =>
payload) as typeof db.policy_documents.create);
});
afterEach(() => { afterEach(() => {
mock.restoreAll(); mock.restoreAll();
}); });
@ -239,4 +245,65 @@ describe('seedDefaultContentForTenant', () => {
); );
assert.ok(Array.isArray(zoneRows.find((row) => row.content_type === 'regulation-zones')?.payload)); assert.ok(Array.isArray(zoneRows.find((row) => row.content_type === 'regulation-zones')?.payload));
}); });
test('seeds ESA funding content and acknowledgment document only at campus scope', async () => {
const createdRows: Array<Record<string, unknown>> = [];
const createdPolicies: Array<Record<string, unknown>> = [];
mock.method(db.content_catalog, 'findOne', (async () => null) as typeof db.content_catalog.findOne);
mock.method(db.content_catalog, 'create', (async (payload: Record<string, unknown>) => {
createdRows.push(payload);
return payload;
}) as typeof db.content_catalog.create);
mock.method(db.policy_documents, 'create', (async (payload: Record<string, unknown>) => {
createdPolicies.push(payload);
return payload;
}) as typeof db.policy_documents.create);
await seedDefaultContentForTenant({
level: 'organization',
organizationId: 'org-1',
});
await seedDefaultContentForTenant({
level: 'school',
organizationId: 'org-1',
schoolId: 'school-1',
});
await seedDefaultContentForTenant({
level: 'campus',
organizationId: 'org-1',
schoolId: 'school-1',
campusId: 'campus-1',
});
const esaRows = createdRows.filter((row) =>
row.content_type === 'esa-funding-content',
);
assert.equal(esaRows.length, 1);
assert.equal(esaRows[0]?.organizationId, 'org-1');
assert.equal(esaRows[0]?.schoolId, null);
assert.equal(esaRows[0]?.campusId, 'campus-1');
assert.equal(esaRows[0]?.classId, null);
assert.equal(esaRows[0]?.active, true);
assert.deepEqual(esaRows[0]?.payload, CONTENT_CATALOG_SEED_PAYLOADS.esaFundingContent);
assert.equal(createdPolicies.length, 1);
assert.deepEqual(createdPolicies[0], {
title: 'ESA Funding Information',
body: 'Staff must review the current ESA Funding Information page and acknowledge understanding of the campus guidance.',
category: 'handbook_policy',
tag: 'ESA Funding',
author: null,
steps: null,
autism_considerations: null,
version: 1,
active: true,
importHash: 'policy-doc-esa-funding-campus-1',
organizationId: 'org-1',
schoolId: null,
campusId: 'campus-1',
classId: null,
});
});
}); });

View File

@ -2,9 +2,11 @@ import type { Transaction } from 'sequelize';
import db from '@/db/models'; import db from '@/db/models';
import { import {
PER_TENANT_CONTENT_TYPES, PER_TENANT_CONTENT_TYPES,
CAMPUS_SCOPED_CONTENT_TYPES,
SCHOOL_SCOPED_CONTENT_TYPES, SCHOOL_SCOPED_CONTENT_TYPES,
ORG_SCOPED_CONTENT_TYPES, ORG_SCOPED_CONTENT_TYPES,
} from '@/shared/constants/content-catalog'; } from '@/shared/constants/content-catalog';
import { POLICY_DOCUMENT_CATEGORIES } from '@/shared/constants/policy-documents';
import { CONTENT_CATALOG_DEFAULT_ROWS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads'; import { CONTENT_CATALOG_DEFAULT_ROWS } from '@/db/seeders/content-catalog-data/content-catalog-seed-payloads';
export type TenantSeedLevel = 'organization' | 'school' | 'campus'; export type TenantSeedLevel = 'organization' | 'school' | 'campus';
@ -27,8 +29,8 @@ interface OwnerStamp {
* created or `null` if this type is not preset at this level. Per-tenant * created or `null` if this type is not preset at this level. Per-tenant
* org-scoped content such as the safety quiz exists only at organization level; * org-scoped content such as the safety quiz exists only at organization level;
* dashboard + parent templates exist at org/school/campus; school-scoped only * dashboard + parent templates exist at org/school/campus; school-scoped only
* at school; truly global types are seeded once (with no tenant) when the first * at school; campus-scoped only at campus; truly global types are seeded once
* org is created. * (with no tenant) when the first org is created.
*/ */
function stampForLevel( function stampForLevel(
contentType: string, contentType: string,
@ -48,6 +50,12 @@ function stampForLevel(
: null; : null;
} }
if (CAMPUS_SCOPED_CONTENT_TYPES.has(contentType)) {
return ctx.level === 'campus' && ctx.campusId
? { organizationId: org, schoolId: null, campusId: ctx.campusId }
: null;
}
if (PER_TENANT_CONTENT_TYPES.has(contentType)) { if (PER_TENANT_CONTENT_TYPES.has(contentType)) {
if (ctx.level === 'organization') { if (ctx.level === 'organization') {
return { organizationId: org, schoolId: null, campusId: null }; return { organizationId: org, schoolId: null, campusId: null };
@ -110,4 +118,35 @@ export async function seedDefaultContentForTenant(
{ transaction }, { transaction },
); );
} }
if (ctx.level === 'campus' && ctx.campusId) {
const importHash = `policy-doc-esa-funding-${ctx.campusId}`;
const existing = await db.policy_documents.findOne({
where: { importHash },
paranoid: false,
transaction,
});
if (!existing) {
await db.policy_documents.create(
{
title: 'ESA Funding Information',
body: 'Staff must review the current ESA Funding Information page and acknowledge understanding of the campus guidance.',
category: POLICY_DOCUMENT_CATEGORIES.HANDBOOK_POLICY,
tag: 'ESA Funding',
author: null,
steps: null,
autism_considerations: null,
version: 1,
active: true,
importHash,
organizationId: ctx.organizationId,
schoolId: null,
campusId: ctx.campusId,
classId: null,
},
{ transaction },
);
}
}
} }

View File

@ -13,7 +13,7 @@ export const PERSONALITY_QUIZ_CONTENT_TYPE = 'emotional-intelligence-personality
/** Sign card library, owned and managed at organization scope. */ /** Sign card library, owned and managed at organization scope. */
export const SIGN_LANGUAGE_ITEMS_CONTENT_TYPE = 'sign-language-items'; export const SIGN_LANGUAGE_ITEMS_CONTENT_TYPE = 'sign-language-items';
/** ESA funding content — school-scoped (rules depend on the school's locale). */ /** ESA funding content — campus-scoped so each campus owns its local guidance. */
export const ESA_CONTENT_TYPE = 'esa-funding-content'; export const ESA_CONTENT_TYPE = 'esa-funding-content';
/** /**
@ -28,11 +28,14 @@ export const PER_TENANT_CONTENT_TYPES: ReadonlySet<string> = new Set([
'dashboard-compliance-items', 'dashboard-compliance-items',
]); ]);
/** **School-scoped** content types (one per school). */ /** **Campus-scoped** content types (class users read the campus row). */
export const SCHOOL_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([ export const CAMPUS_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
ESA_CONTENT_TYPE, ESA_CONTENT_TYPE,
]); ]);
/** **School-scoped** content types (one per school). */
export const SCHOOL_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([]);
/** **Org-scoped** content types (one per organization; preset at org creation). */ /** **Org-scoped** content types (one per organization; preset at org creation). */
export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([ export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
SAFETY_QUIZ_CONTENT_TYPE, SAFETY_QUIZ_CONTENT_TYPE,
@ -52,11 +55,13 @@ export const ORG_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
]); ]);
/** /**
* All tenant-scoped content types (per-tenant school org). Truly global * All tenant-scoped content types (per-tenant campus school org). Truly
* personality and classroom-timer catalogs live in frontend static constants. * global personality and classroom-timer catalogs live in frontend static
* constants.
*/ */
export const TENANT_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([ export const TENANT_SCOPED_CONTENT_TYPES: ReadonlySet<string> = new Set([
...PER_TENANT_CONTENT_TYPES, ...PER_TENANT_CONTENT_TYPES,
...CAMPUS_SCOPED_CONTENT_TYPES,
...SCHOOL_SCOPED_CONTENT_TYPES, ...SCHOOL_SCOPED_CONTENT_TYPES,
...ORG_SCOPED_CONTENT_TYPES, ...ORG_SCOPED_CONTENT_TYPES,
]); ]);

View File

@ -63,6 +63,7 @@ export const MODULE_MANAGEMENT_PERMISSIONS = [
'MANAGE_WALKTHROUGH', 'MANAGE_WALKTHROUGH',
'MANAGE_INTERNAL_COMM', 'MANAGE_INTERNAL_COMM',
'MANAGE_CONTENT_CATALOG', 'MANAGE_CONTENT_CATALOG',
'MANAGE_ESA_FUNDING_CONTENT',
'READ_STAFF_ATTENDANCE_REPORTS', 'READ_STAFF_ATTENDANCE_REPORTS',
'READ_SAFETY_QUIZ_REPORTS', 'READ_SAFETY_QUIZ_REPORTS',
'READ_PERSONALITY_REPORTS', 'READ_PERSONALITY_REPORTS',
@ -107,6 +108,7 @@ export const FEATURE_PERMISSIONS = Object.freeze({
MANAGE_WALKTHROUGH: 'MANAGE_WALKTHROUGH', MANAGE_WALKTHROUGH: 'MANAGE_WALKTHROUGH',
MANAGE_INTERNAL_COMM: 'MANAGE_INTERNAL_COMM', MANAGE_INTERNAL_COMM: 'MANAGE_INTERNAL_COMM',
MANAGE_CONTENT_CATALOG: 'MANAGE_CONTENT_CATALOG', MANAGE_CONTENT_CATALOG: 'MANAGE_CONTENT_CATALOG',
MANAGE_ESA_FUNDING_CONTENT: 'MANAGE_ESA_FUNDING_CONTENT',
READ_STAFF_ATTENDANCE_REPORTS: 'READ_STAFF_ATTENDANCE_REPORTS', READ_STAFF_ATTENDANCE_REPORTS: 'READ_STAFF_ATTENDANCE_REPORTS',
READ_SAFETY_QUIZ_REPORTS: 'READ_SAFETY_QUIZ_REPORTS', READ_SAFETY_QUIZ_REPORTS: 'READ_SAFETY_QUIZ_REPORTS',
READ_PERSONALITY_REPORTS: 'READ_PERSONALITY_REPORTS', READ_PERSONALITY_REPORTS: 'READ_PERSONALITY_REPORTS',

View File

@ -129,7 +129,11 @@ These dashboard catalog rows are seeded per tenant at organization, school, and
## Editable ESA Funding Content ## Editable ESA Funding Content
ESA funding approved uses, key points, state checklist items, school impact items, staff role guidance, parent conversation script, and resource records are part of the `esa-funding-content` content catalog payload. Static ESA intro copy and FAQs live in `frontend/src/shared/constants/esaFunding.ts` because they are stable training copy, not editable runtime records. ESA funding school impact items, staff role guidance, FAQ items with per-question audience visibility, parent conversation Q&A references, and resource records are part of the campus-scoped `esa-funding-content` content catalog payload. Users with `MANAGE_ESA_FUNDING_CONTENT` can edit that payload only from a campus effective scope; organization/school/platform users manage it by drilling into a campus.
Static ESA intro copy, the state-variation notice/checklist, key points, and approved-use cards live in `frontend/src/shared/constants/esaFunding.ts` because they are stable training copy, not editable runtime records. Student and guardian views read the same campus content but hide staff-only FAQ entries, staff role guidance, parent conversation references, and the staff acknowledgment card.
Editing `esa-funding-content` sends the changed section name to the backend. The content catalog service bumps the linked campus `policy_documents` row tagged `ESA Funding`, which resets current-version staff acknowledgment state and drives header notifications plus leadership acknowledgment reporting.
## Editable Community Organization Content ## Editable Community Organization Content

View File

@ -8,7 +8,7 @@
View -> Business Logic -> API/Data Access -> Backend View -> Business Logic -> API/Data Access -> Backend
``` ```
Static ESA explanatory copy and FAQs live in dedicated frontend constants. Editable ESA lists, staff role guidance, parent conversation script, and resource records belong to the backend content catalog. The frontend also owns local UI state, URL validation, acknowledgement interaction state, and presentation. Static ESA explanatory copy, the state-variation notice/checklist, key points, and approved-use cards live in dedicated frontend constants. Editable campus ESA content lives in the backend content catalog. Staff acknowledgments use the existing policy-document acknowledgment subsystem, so they appear in the header notification flow and leadership acknowledgment reporting.
## Frontend Layers ## Frontend Layers
@ -26,6 +26,7 @@ View:
- `frontend/src/components/esa-funding/EsaFundingQuickReference.tsx` - `frontend/src/components/esa-funding/EsaFundingQuickReference.tsx`
- `frontend/src/components/esa-funding/EsaFundingResources.tsx` - `frontend/src/components/esa-funding/EsaFundingResources.tsx`
- `frontend/src/components/esa-funding/EsaFundingAcknowledgement.tsx` - `frontend/src/components/esa-funding/EsaFundingAcknowledgement.tsx`
- `frontend/src/components/esa-funding/EsaFundingEditor.tsx`
- `frontend/src/components/esa-funding/EsaFundingIcon.tsx` - `frontend/src/components/esa-funding/EsaFundingIcon.tsx`
Business logic: Business logic:
@ -45,24 +46,31 @@ Shared contracts:
The page reads: The page reads:
- `GET /api/content-catalog/read/esa-funding-content` - `GET /api/content-catalog/read/esa-funding-content`
- `GET /api/policy_documents?category=handbook_policy&tag=ESA+Funding`
- `GET /api/policy_acknowledgments`
- `POST /api/policy_acknowledgments`
Content payload is seeded in: Content payload is seeded in:
- `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.js` - `backend/src/db/seeders/content-catalog-data/content-catalog-seed-payloads.ts`
## Behavior ## Behavior
- `useEsaFundingPage` loads ESA funding content and owns local FAQ expansion plus acknowledgement state. - `useEsaFundingPage` loads campus-scoped ESA funding content and owns local FAQ expansion state.
- Static ESA intro, state notice copy, and FAQs are read from `frontend/src/shared/constants/esaFunding.ts`. - Static ESA intro, state notice/checklist, key points, and approved-use cards are read from `frontend/src/shared/constants/esaFunding.ts`.
- Selectors handle FAQ toggling and resource URL validation. - Selectors normalize legacy payloads, filter staff-only FAQ items for student/guardian users, handle FAQ toggling, and validate resource URLs.
- View components receive a prepared page model and do not call API/data access modules. - View components receive a prepared page model and do not call API/data access modules.
- Loading and error states are explicit through `StatePanel`. - Loading and error states are explicit through `StatePanel`.
- Resource records with valid `http` or `https` URLs render as external links. Invalid or placeholder URLs render as unavailable instead of using no-op click handlers. - Resource records with valid `http` or `https` URLs render as external links. Invalid or placeholder URLs render as unavailable instead of using no-op click handlers.
- `MANAGE_ESA_FUNDING_CONTENT` users can edit the campus-scoped dynamic payload from campus effective scope. Parent-scope users manage a campus by drilling into that campus.
- ESA funding content editing is embedded inside each dynamic content section as a collapsible local editor next to the content it changes.
- Student and guardian users do not see staff-role guidance, the parent-conversation quick reference, or the staff acknowledgment card.
- Staff acknowledgments are persisted per current ESA policy-document version through `policy_acknowledgments`; editing ESA content sends the section name to the backend, bumps the linked document version, and drives the header notification text.
## Data Ownership Rules ## Data Ownership Rules
- Static ESA explanatory copy and FAQs may live in `frontend/src/shared/constants/esaFunding.ts`. - Static ESA explanatory copy, state checklist, key points, and approved-use cards may live in `frontend/src/shared/constants/esaFunding.ts`.
- Do not add editable ESA funding records such as approved uses, key points, checklist items, role guidance, conversation scripts, or resource records to frontend constants. - Editable ESA funding records such as school impact items, staff role guidance, FAQs, parent conversation Q&A references, and resource records belong to backend `esa-funding-content`.
- Do not add frontend fallback ESA content payloads. - Do not add frontend fallback ESA content payloads.
- Keep frontend logic limited to workflow state, resource URL validation, icon mapping, and presentation. - Keep frontend logic limited to workflow state, audience filtering, resource URL validation, icon mapping, and presentation.
- Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`. - Test-only fixtures may live in selector tests or `frontend/src/test-seeds/`.

View File

@ -30,10 +30,14 @@ export function useContentCatalogPayload<TPayload>(
}; };
} }
export function useManagedContentCatalog<TPayload>(contentType: string) { export function useManagedContentCatalog<TPayload>(
contentType: string,
options?: { readonly enabled?: boolean },
) {
return useQuery({ return useQuery({
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType], queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', contentType],
queryFn: () => getManagedContentCatalog<TPayload>(contentType), queryFn: () => getManagedContentCatalog<TPayload>(contentType),
enabled: options?.enabled ?? true,
}); });
} }

View File

@ -1,38 +1,154 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import {
import { toggleEsaFaq } from '@/business/esa-funding/selectors'; useContentCatalogPayload,
useManagedContentCatalog,
} from '@/business/content-catalog/hooks';
import {
filterEsaFaqsByAudience,
isEsaExternalAudience,
normalizeEsaFundingContent,
toggleEsaFaq,
} from '@/business/esa-funding/selectors';
import type { EsaFundingPage } from '@/business/esa-funding/types'; import type { EsaFundingPage } from '@/business/esa-funding/types';
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
import { ESA_FUNDING_FAQS } from '@/shared/constants/esaFunding';
import type { EsaFundingContent } from '@/shared/types/esaFunding'; import type { EsaFundingContent } from '@/shared/types/esaFunding';
import { useAuth } from '@/shared/app/useAuth';
import { useScopeContext } from '@/shared/app/scope-context';
import { usePermissions } from '@/shared/app/usePermissions';
import {
useAcknowledgePolicy,
useEsaFundingPolicyDocument,
usePolicyAcknowledgments,
} from '@/business/policies/hooks';
import { isPolicyDocumentAcknowledged } from '@/business/policies/selectors';
import {
createManagedContentCatalog,
updateManagedContentCatalog,
} from '@/shared/api/contentCatalog';
import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog';
import { POLICY_QUERY_KEYS } from '@/shared/constants/policies';
interface EsaFundingSaveInput {
readonly content: EsaFundingContent;
readonly sectionName: string;
}
export const EMPTY_ESA_FUNDING_CONTENT: EsaFundingContent = { export const EMPTY_ESA_FUNDING_CONTENT: EsaFundingContent = {
approvedUses: [],
keyPoints: [],
stateChecklist: [],
schoolImpactItems: [], schoolImpactItems: [],
staffRoleItems: [], staffRoleItems: [],
parentConversationScript: '', faqs: [],
parentConversationReferences: [],
resources: [], resources: [],
}; };
export function useEsaFundingPage(): EsaFundingPage { export function useEsaFundingPage(): EsaFundingPage {
const { user } = useAuth();
const { effectiveTenant } = useScopeContext();
const permissions = usePermissions();
const queryClient = useQueryClient();
const canManage = permissions.has('MANAGE_ESA_FUNDING_CONTENT')
&& effectiveTenant?.level === 'campus';
const externalAudience = isEsaExternalAudience(user);
const hasCampusContentScope = effectiveTenant?.level === 'campus'
|| effectiveTenant?.level === 'class'
|| Boolean(user?.campusId);
const contentQuery = useContentCatalogPayload<EsaFundingContent>( const contentQuery = useContentCatalogPayload<EsaFundingContent>(
CONTENT_CATALOG_TYPES.esaFundingContent, CONTENT_CATALOG_TYPES.esaFundingContent,
EMPTY_ESA_FUNDING_CONTENT, EMPTY_ESA_FUNDING_CONTENT,
{ enabled: hasCampusContentScope },
); );
const managedQuery = useManagedContentCatalog<EsaFundingContent>(
CONTENT_CATALOG_TYPES.esaFundingContent,
{ enabled: canManage },
);
const saveContentMutation = useMutation({
mutationFn: ({ content: nextContent, sectionName }: EsaFundingSaveInput) => (
managedQuery.data
? updateManagedContentCatalog(CONTENT_CATALOG_TYPES.esaFundingContent, {
payload: nextContent,
changeSummary: sectionName,
})
: createManagedContentCatalog({
content_type: CONTENT_CATALOG_TYPES.esaFundingContent,
payload: nextContent,
changeSummary: sectionName,
})
),
onSuccess: async (response) => {
const normalizedPayload = normalizeEsaFundingContent(response.payload);
queryClient.setQueryData(
[...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.esaFundingContent],
normalizedPayload,
);
queryClient.setQueryData(
[...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', CONTENT_CATALOG_TYPES.esaFundingContent],
{
...response,
payload: normalizedPayload,
},
);
await Promise.all([
queryClient.invalidateQueries({
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.esaFundingContent],
}),
queryClient.invalidateQueries({
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, 'managed', CONTENT_CATALOG_TYPES.esaFundingContent],
}),
queryClient.invalidateQueries({ queryKey: POLICY_QUERY_KEYS.esaDocuments }),
queryClient.invalidateQueries({ queryKey: POLICY_QUERY_KEYS.acknowledgments }),
]);
},
});
const canAcknowledge = !externalAudience && permissions.has('ACK_POLICY');
const policyDocumentQuery = useEsaFundingPolicyDocument(canAcknowledge);
const acknowledgmentsQuery = usePolicyAcknowledgments(canAcknowledge);
const acknowledgePolicy = useAcknowledgePolicy();
const [expandedFAQ, setExpandedFAQ] = useState<number | null>(0); const [expandedFAQ, setExpandedFAQ] = useState<number | null>(0);
const [acknowledged, setAcknowledged] = useState(false); const normalizedContent = normalizeEsaFundingContent(contentQuery.payload);
const content = externalAudience
? {
...normalizedContent,
staffRoleItems: [],
parentConversationReferences: [],
}
: normalizedContent;
const policyDocument = policyDocumentQuery.data ?? null;
const acknowledged = canAcknowledge && policyDocument
? isPolicyDocumentAcknowledged(
acknowledgmentsQuery.data ?? [],
policyDocument.id,
policyDocument.version,
)
: false;
return { return {
content: contentQuery.payload, content,
faqs: ESA_FUNDING_FAQS, faqs: filterEsaFaqsByAudience(content.faqs, externalAudience),
expandedFAQ, expandedFAQ,
acknowledged, acknowledged,
canManage,
canAcknowledge,
isSaving: saveContentMutation.isPending || managedQuery.isLoading,
isLoading: contentQuery.isLoading, isLoading: contentQuery.isLoading,
error: contentQuery.error, error: contentQuery.error
?? managedQuery.error
?? saveContentMutation.error
?? acknowledgePolicy.error,
toggleFAQ: (index) => setExpandedFAQ((currentIndex) => toggleEsaFaq(currentIndex, index)), toggleFAQ: (index) => setExpandedFAQ((currentIndex) => toggleEsaFaq(currentIndex, index)),
toggleAcknowledged: () => setAcknowledged((current) => !current), toggleAcknowledged: () => {
if (canAcknowledge && policyDocument && !acknowledged) {
acknowledgePolicy.mutate(policyDocument.id);
}
},
saveContent: async (nextContent, sectionName) => {
await saveContentMutation.mutateAsync({
content: normalizeEsaFundingContent(nextContent),
sectionName,
});
},
}; };
} }

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
isValidEsaResourceUrl, isValidEsaResourceUrl,
normalizeEsaFundingContent,
toggleEsaFaq, toggleEsaFaq,
} from '@/business/esa-funding/selectors'; } from '@/business/esa-funding/selectors';
@ -19,4 +20,48 @@ describe('ESA funding selectors', () => {
expect(isValidEsaResourceUrl('mailto:test@example.com')).toBe(false); expect(isValidEsaResourceUrl('mailto:test@example.com')).toBe(false);
expect(isValidEsaResourceUrl('not a url')).toBe(false); expect(isValidEsaResourceUrl('not a url')).toBe(false);
}); });
it('normalizes multiple parent conversation references', () => {
const content = normalizeEsaFundingContent({
parentConversationReferences: [
{ id: 'reference-one', question: 'First question', answer: 'First answer' },
{ question: 'Second question', answer: 'Second answer' },
],
});
expect(content.parentConversationReferences).toEqual([
{ id: 'reference-one', question: 'First question', answer: 'First answer' },
{ id: 'parent-reference-second-question', question: 'Second question', answer: 'Second answer' },
]);
});
it('converts legacy parent conversation reference text into Q&A format', () => {
const content = normalizeEsaFundingContent({
parentConversationReferences: [
{ id: 'reference-one', text: 'First reference' },
],
});
expect(content.parentConversationReferences).toEqual([
{
id: 'reference-one',
question: 'If a parent asks about ESA, here is a simple way to explain it:',
answer: 'First reference',
},
]);
});
it('converts legacy parent conversation script into one reference', () => {
const content = normalizeEsaFundingContent({
parentConversationScript: 'Legacy script',
});
expect(content.parentConversationReferences).toEqual([
{
id: 'parent-reference-legacy-script',
question: 'If a parent asks about ESA, here is a simple way to explain it:',
answer: 'Legacy script',
},
]);
});
}); });

View File

@ -1,3 +1,16 @@
import type { CurrentUser } from '@/shared/types/auth';
import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding';
import type {
EsaFaqItem,
EsaFundingContent,
EsaParentConversationReference,
EsaResource,
EsaSchoolImpactItem,
EsaStaffRoleItem,
} from '@/shared/types/esaFunding';
const DEFAULT_PARENT_CONVERSATION_QUESTION = ESA_FUNDING_STATIC_COPY.parentConversationIntro;
export function toggleEsaFaq(currentIndex: number | null, nextIndex: number): number | null { export function toggleEsaFaq(currentIndex: number | null, nextIndex: number): number | null {
return currentIndex === nextIndex ? null : nextIndex; return currentIndex === nextIndex ? null : nextIndex;
} }
@ -15,3 +28,222 @@ export function isValidEsaResourceUrl(url: string): boolean {
return false; return false;
} }
} }
export function isEsaExternalAudience(user: CurrentUser | null | undefined): boolean {
const roleName = user?.app_role?.name;
return roleName === 'student' || roleName === 'guardian';
}
export function filterEsaFaqsByAudience(
faqs: readonly EsaFaqItem[],
externalAudience: boolean,
): readonly EsaFaqItem[] {
return faqs.filter((faq) => !externalAudience || faq.audience === 'all');
}
function stableId(prefix: string, value: string, index: number): string {
const slug = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40);
return `${prefix}-${slug || index + 1}`;
}
function normalizeSchoolImpactItems(value: unknown): readonly EsaSchoolImpactItem[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item, index): EsaSchoolImpactItem | null => {
if (typeof item === 'string') {
return { id: stableId('impact', item, index), text: item };
}
if (
item
&& typeof item === 'object'
&& 'text' in item
&& typeof item.text === 'string'
) {
return {
id: 'id' in item && typeof item.id === 'string'
? item.id
: stableId('impact', item.text, index),
text: item.text,
};
}
return null;
})
.filter((item): item is EsaSchoolImpactItem => item !== null);
}
function normalizeStaffRoleItems(value: unknown): readonly EsaStaffRoleItem[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item, index): EsaStaffRoleItem | null => {
if (
item
&& typeof item === 'object'
&& 'title' in item
&& typeof item.title === 'string'
&& 'description' in item
&& typeof item.description === 'string'
) {
return {
id: 'id' in item && typeof item.id === 'string'
? item.id
: stableId('staff', item.title, index),
title: item.title,
description: item.description,
};
}
return null;
})
.filter((item): item is EsaStaffRoleItem => item !== null);
}
function normalizeFaqs(value: unknown): readonly EsaFaqItem[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item, index): EsaFaqItem | null => {
if (
item
&& typeof item === 'object'
&& 'question' in item
&& typeof item.question === 'string'
&& 'answer' in item
&& typeof item.answer === 'string'
) {
const audience = 'audience' in item && item.audience === 'staff'
? 'staff'
: 'all';
return {
id: 'id' in item && typeof item.id === 'string'
? item.id
: stableId('faq', item.question, index),
question: item.question,
answer: item.answer,
audience,
};
}
return null;
})
.filter((item): item is EsaFaqItem => item !== null);
}
function normalizeResources(value: unknown): readonly EsaResource[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item, index): EsaResource | null => {
if (
item
&& typeof item === 'object'
&& 'title' in item
&& typeof item.title === 'string'
&& 'description' in item
&& typeof item.description === 'string'
&& 'url' in item
&& typeof item.url === 'string'
) {
return {
id: 'id' in item && typeof item.id === 'string'
? item.id
: stableId('resource', item.title, index),
title: item.title,
description: item.description,
url: item.url,
};
}
return null;
})
.filter((item): item is EsaResource => item !== null);
}
function normalizeParentConversationReferences(
value: unknown,
legacyScript: unknown,
): readonly EsaParentConversationReference[] {
if (Array.isArray(value)) {
return value
.map((item, index): EsaParentConversationReference | null => {
if (typeof item === 'string') {
return {
id: stableId('parent-reference', item, index),
question: DEFAULT_PARENT_CONVERSATION_QUESTION,
answer: item,
};
}
if (
item
&& typeof item === 'object'
) {
if (
'question' in item
&& typeof item.question === 'string'
&& 'answer' in item
&& typeof item.answer === 'string'
) {
return {
id: 'id' in item && typeof item.id === 'string'
? item.id
: stableId('parent-reference', item.question, index),
question: item.question,
answer: item.answer,
};
}
if ('text' in item && typeof item.text === 'string') {
return {
id: 'id' in item && typeof item.id === 'string'
? item.id
: stableId('parent-reference', item.text, index),
question: DEFAULT_PARENT_CONVERSATION_QUESTION,
answer: item.text,
};
}
return null;
}
return null;
})
.filter((item): item is EsaParentConversationReference => item !== null);
}
if (typeof legacyScript === 'string' && legacyScript.trim().length > 0) {
return [{
id: stableId('parent-reference', legacyScript, 0),
question: DEFAULT_PARENT_CONVERSATION_QUESTION,
answer: legacyScript,
}];
}
return [];
}
export function normalizeEsaFundingContent(content: EsaFundingContent | unknown): EsaFundingContent {
const source = content && typeof content === 'object' ? content : {};
return {
schoolImpactItems: normalizeSchoolImpactItems(
'schoolImpactItems' in source ? source.schoolImpactItems : [],
),
staffRoleItems: normalizeStaffRoleItems(
'staffRoleItems' in source ? source.staffRoleItems : [],
),
faqs: normalizeFaqs('faqs' in source ? source.faqs : []),
parentConversationReferences: normalizeParentConversationReferences(
'parentConversationReferences' in source ? source.parentConversationReferences : undefined,
'parentConversationScript' in source ? source.parentConversationScript : '',
),
resources: normalizeResources('resources' in source ? source.resources : []),
};
}

View File

@ -6,8 +6,12 @@ export interface EsaFundingPage {
readonly faqs: readonly EsaFaqItem[]; readonly faqs: readonly EsaFaqItem[];
readonly expandedFAQ: number | null; readonly expandedFAQ: number | null;
readonly acknowledged: boolean; readonly acknowledged: boolean;
readonly canManage: boolean;
readonly canAcknowledge: boolean;
readonly isSaving: boolean;
readonly isLoading: boolean; readonly isLoading: boolean;
readonly error: Error | null; readonly error: Error | null;
readonly toggleFAQ: (index: number) => void; readonly toggleFAQ: (index: number) => void;
readonly toggleAcknowledged: () => void; readonly toggleAcknowledged: () => void;
readonly saveContent: (content: EsaFundingContent, sectionName: string) => Promise<void>;
} }

View File

@ -14,6 +14,7 @@ import {
POLICY_DOCUMENT_PAGE_CATEGORY, POLICY_DOCUMENT_PAGE_CATEGORY,
POLICY_QUERY_KEYS, POLICY_QUERY_KEYS,
} from '@/shared/constants/policies'; } from '@/shared/constants/policies';
import { ESA_FUNDING_POLICY_TAG } from '@/shared/constants/esaFunding';
import { toPolicyDocumentMutationDto, toPolicyViewModel } from '@/business/policies/mappers'; import { toPolicyDocumentMutationDto, toPolicyViewModel } from '@/business/policies/mappers';
import type { PolicyFormInput } from '@/business/policies/types'; import type { PolicyFormInput } from '@/business/policies/types';
import { getApiListRows, mapApiListRows } from '@/shared/business/apiListRows'; import { getApiListRows, mapApiListRows } from '@/shared/business/apiListRows';
@ -32,10 +33,27 @@ export function usePolicies(enabled = true) {
mapApiListRows( mapApiListRows(
listPolicyDocuments(POLICY_DOCUMENT_PAGE_CATEGORY.handbookPolicies), listPolicyDocuments(POLICY_DOCUMENT_PAGE_CATEGORY.handbookPolicies),
toPolicyViewModel, toPolicyViewModel,
).then((policies) =>
policies.filter((policy) => policy.tag !== ESA_FUNDING_POLICY_TAG),
), ),
}); });
} }
export function useEsaFundingPolicyDocument(enabled = true) {
return useQuery({
queryKey: POLICY_QUERY_KEYS.esaDocuments,
enabled,
queryFn: async () => {
const rows = await getApiListRows(
listPolicyDocuments(POLICY_DOCUMENT_PAGE_CATEGORY.handbookPolicies, {
tag: ESA_FUNDING_POLICY_TAG,
}),
);
return rows[0] ? toPolicyViewModel(rows[0]) : null;
},
});
}
export function useCreatePolicy() { export function useCreatePolicy() {
return useInvalidatingMutation({ return useInvalidatingMutation({
mutationFn: (input: PolicyFormInput) => mutationFn: (input: PolicyFormInput) =>

View File

@ -38,6 +38,7 @@ describe('policy mappers', () => {
id: 'policy-1', id: 'policy-1',
title: 'Incident Response', title: 'Incident Response',
category: 'Safety', category: 'Safety',
tag: 'Safety',
content: 'Use the approved incident response process.', content: 'Use the approved incident response process.',
version: 2, version: 2,
lastUpdated: '2026-06-08', lastUpdated: '2026-06-08',
@ -54,6 +55,7 @@ describe('policy mappers', () => {
id: 'policy-1', id: 'policy-1',
title: '', title: '',
category: POLICY_DEFAULT_CATEGORY, category: POLICY_DEFAULT_CATEGORY,
tag: 'Unknown Tag',
content: '', content: '',
version: 2, version: 2,
lastUpdated: POLICY_DATE_NOT_RECORDED_LABEL, lastUpdated: POLICY_DATE_NOT_RECORDED_LABEL,

View File

@ -36,6 +36,7 @@ export function toPolicyViewModel(dto: PolicyDocumentDto): PolicyViewModel {
id: dto.id, id: dto.id,
title: dto.title || '', title: dto.title || '',
category: toPolicyCategory(dto.tag), category: toPolicyCategory(dto.tag),
tag: dto.tag,
content: dto.body || '', content: dto.body || '',
version: dto.version, version: dto.version,
lastUpdated: toDateOnly(dto.updatedAt), lastUpdated: toDateOnly(dto.updatedAt),

View File

@ -15,7 +15,7 @@ import {
isPolicyFormValid, isPolicyFormValid,
} from '@/business/policies/selectors'; } from '@/business/policies/selectors';
import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
import type { PolicyCategory, PolicyFormInput } from '@/business/policies/types'; import type { PolicyCategory, PolicyFormInput, PolicyViewModel } from '@/business/policies/types';
import { POLICY_DEFAULT_CATEGORY } from '@/shared/constants/policies'; import { POLICY_DEFAULT_CATEGORY } from '@/shared/constants/policies';
import { usePermissions } from '@/shared/app/usePermissions'; import { usePermissions } from '@/shared/app/usePermissions';
import { useScopeContext } from '@/shared/app/scope-context'; import { useScopeContext } from '@/shared/app/scope-context';
@ -24,7 +24,7 @@ export interface PoliciesPageState {
readonly canManage: boolean; readonly canManage: boolean;
readonly canAcknowledge: boolean; readonly canAcknowledge: boolean;
readonly canPersistAcknowledgments: boolean; readonly canPersistAcknowledgments: boolean;
readonly filteredPolicies: NonNullable<ReturnType<typeof usePolicies>['data']>; readonly filteredPolicies: readonly PolicyViewModel[];
readonly categories: readonly (PolicyCategory | 'all')[]; readonly categories: readonly (PolicyCategory | 'all')[];
readonly searchQuery: string; readonly searchQuery: string;
readonly categoryFilter: PolicyCategory | 'all'; readonly categoryFilter: PolicyCategory | 'all';

View File

@ -15,6 +15,7 @@ const policies: readonly PolicyViewModel[] = [
id: 'policy-1', id: 'policy-1',
title: 'Emergency Communication', title: 'Emergency Communication',
category: 'Communication', category: 'Communication',
tag: 'Communication',
content: 'Call families after a campus-wide emergency.', content: 'Call families after a campus-wide emergency.',
version: 1, version: 1,
lastUpdated: '2026-06-08', lastUpdated: '2026-06-08',
@ -24,6 +25,7 @@ const policies: readonly PolicyViewModel[] = [
id: 'policy-2', id: 'policy-2',
title: 'Safety Drill', title: 'Safety Drill',
category: 'Safety', category: 'Safety',
tag: 'Safety',
content: 'Monthly drill expectations.', content: 'Monthly drill expectations.',
version: 1, version: 1,
lastUpdated: '2026-06-07', lastUpdated: '2026-06-07',
@ -33,6 +35,7 @@ const policies: readonly PolicyViewModel[] = [
id: 'policy-3', id: 'policy-3',
title: 'Behavior Support', title: 'Behavior Support',
category: 'Behavior', category: 'Behavior',
tag: 'Behavior',
content: 'Use approved de-escalation supports.', content: 'Use approved de-escalation supports.',
version: 1, version: 1,
lastUpdated: '2026-06-06', lastUpdated: '2026-06-06',

View File

@ -6,6 +6,7 @@ export interface PolicyViewModel {
readonly id: string; readonly id: string;
readonly title: string; readonly title: string;
readonly category: PolicyCategory; readonly category: PolicyCategory;
readonly tag: string | null;
readonly content: string; readonly content: string;
readonly version: number; readonly version: number;
readonly lastUpdated: string; readonly lastUpdated: string;

View File

@ -55,6 +55,7 @@ function createPolicy(overrides: Partial<PolicyViewModel> = {}): PolicyViewModel
id: 'policy-1', id: 'policy-1',
title: 'Staff Handbook', title: 'Staff Handbook',
category: 'Operations', category: 'Operations',
tag: 'Operations',
content: 'Handbook content', content: 'Handbook content',
version: 2, version: 2,
lastUpdated: '2026-06-18T10:00:00.000Z', lastUpdated: '2026-06-18T10:00:00.000Z',

View File

@ -24,7 +24,11 @@ import {
normalizeSignLanguageItems, normalizeSignLanguageItems,
selectSignLanguageSignOfWeek, selectSignLanguageSignOfWeek,
} from '@/business/sign-language/selectors'; } from '@/business/sign-language/selectors';
import { usePolicies, usePolicyAcknowledgments } from '@/business/policies/hooks'; import {
useEsaFundingPolicyDocument,
usePolicies,
usePolicyAcknowledgments,
} from '@/business/policies/hooks';
import { useCurrentPersonalityResult } from '@/business/personality/queryHooks'; import { useCurrentPersonalityResult } from '@/business/personality/queryHooks';
import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks'; import { useMySafetyQuizStatus } from '@/business/safety-quiz/hooks';
import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors';
@ -169,10 +173,12 @@ export function useTopBarPage({
const canReceivePolicyNotifications = canPersistPersonalResults && hasPermission(user, 'ACK_POLICY'); const canReceivePolicyNotifications = canPersistPersonalResults && hasPermission(user, 'ACK_POLICY');
const canReadHandbook = canReceivePolicyNotifications && accessibleModuleIds.has('handbook'); const canReadHandbook = canReceivePolicyNotifications && accessibleModuleIds.has('handbook');
const canReadSafetyProtocols = canReceivePolicyNotifications && accessibleModuleIds.has('safety'); const canReadSafetyProtocols = canReceivePolicyNotifications && accessibleModuleIds.has('safety');
const canReadEsaFunding = canReceivePolicyNotifications && accessibleModuleIds.has('esa');
const policyAcknowledgments = usePolicyAcknowledgments(canReceivePolicyNotifications && ( const policyAcknowledgments = usePolicyAcknowledgments(canReceivePolicyNotifications && (
canReadHandbook || canReadSafetyProtocols canReadHandbook || canReadSafetyProtocols || canReadEsaFunding
)); ));
const handbookPolicies = usePolicies(canReadHandbook); const handbookPolicies = usePolicies(canReadHandbook);
const esaPolicyDocument = useEsaFundingPolicyDocument(canReadEsaFunding);
const safetyProtocols = useSafetyProtocols(canReadSafetyProtocols); const safetyProtocols = useSafetyProtocols(canReadSafetyProtocols);
// Header search = accessible modules (local) + their product content from the // Header search = accessible modules (local) + their product content from the
// content catalog. Content is fetched lazily — only once the user types, and // content catalog. Content is fetched lazily — only once the user types, and
@ -251,7 +257,12 @@ export function useTopBarPage({
needsPersonalityQuiz, needsPersonalityQuiz,
communicationEvents: communicationEvents.data ?? [], communicationEvents: communicationEvents.data ?? [],
acknowledgedCommunicationEventIds, acknowledgedCommunicationEventIds,
handbookPolicies: handbookPolicies.data ?? [], handbookPolicies: [
...(handbookPolicies.data ?? []),
...(esaPolicyDocument.data
? [esaPolicyDocument.data]
: []),
],
safetyProtocols: safetyProtocols.data ?? [], safetyProtocols: safetyProtocols.data ?? [],
policyAcknowledgments: policyAcknowledgments.data ?? [], policyAcknowledgments: policyAcknowledgments.data ?? [],
}); });

View File

@ -186,6 +186,7 @@ describe('top bar selectors', () => {
id: 'handbook-1', id: 'handbook-1',
title: 'Parent Communication', title: 'Parent Communication',
category: 'Communication', category: 'Communication',
tag: 'Communication',
content: 'Document families contact.', content: 'Document families contact.',
version: 2, version: 2,
lastUpdated: '2026-06-15', lastUpdated: '2026-06-15',
@ -216,6 +217,33 @@ describe('top bar selectors', () => {
}]); }]);
}); });
it('surfaces ESA funding updates with the updated section name', () => {
const unread = buildTopBarNotifications({
needsZoneCheckIn: false,
handbookPolicies: [{
id: 'esa-policy-1',
title: 'ESA Funding Information',
category: 'Operations',
tag: 'ESA Funding',
content: 'Staff must review the current ESA Funding Information page.\n\nUpdated section: Helpful Resources',
version: 3,
lastUpdated: '2026-06-22',
updatedBy: 'Director',
}],
policyAcknowledgments: [
createPolicyAcknowledgment({ policyDocumentId: 'esa-policy-1', version: 2 }),
],
});
expect(unread).toEqual([{
id: 'handbook-policy-esa-policy-1-v3',
text: 'Updated ESA funding section: Helpful Resources',
time: 'Version 3',
unread: true,
href: APP_ROUTE_PATHS.esa,
}]);
});
it('falls back to default campus label', () => { it('falls back to default campus label', () => {
expect(getTopBarCampusLabel()).toBe('Current Campus'); expect(getTopBarCampusLabel()).toBe('Current Campus');
}); });

View File

@ -11,6 +11,7 @@ import type { PolicyViewModel } from '@/business/policies/types';
import type { SafetyProtocolViewModel } from '@/business/safety-protocols/types'; import type { SafetyProtocolViewModel } from '@/business/safety-protocols/types';
import type { PolicyAcknowledgmentDto } from '@/shared/types/policyDocuments'; import type { PolicyAcknowledgmentDto } from '@/shared/types/policyDocuments';
import { isPolicyDocumentAcknowledged } from '@/business/policies/selectors'; import { isPolicyDocumentAcknowledged } from '@/business/policies/selectors';
import { ESA_FUNDING_POLICY_TAG } from '@/shared/constants/esaFunding';
export function getTopBarInitials(name: string): string { export function getTopBarInitials(name: string): string {
return name return name
@ -171,12 +172,20 @@ export function buildTopBarNotifications(input: {
continue; continue;
} }
const esaUpdatedSection = policy.tag === ESA_FUNDING_POLICY_TAG
? getEsaUpdatedSection(policy)
: null;
notifications.push({ notifications.push({
id: `handbook-policy-${policy.id}-v${policy.version}`, id: `handbook-policy-${policy.id}-v${policy.version}`,
text: `Unread handbook policy: ${policy.title}`, text: policy.tag === ESA_FUNDING_POLICY_TAG
? `Updated ESA funding section: ${esaUpdatedSection ?? policy.title}`
: `Unread handbook policy: ${policy.title}`,
time: `Version ${policy.version}`, time: `Version ${policy.version}`,
unread: true, unread: true,
href: APP_ROUTE_PATHS.handbook, href: policy.tag === ESA_FUNDING_POLICY_TAG
? APP_ROUTE_PATHS.esa
: APP_ROUTE_PATHS.handbook,
}); });
} }
@ -196,3 +205,18 @@ export function buildTopBarNotifications(input: {
return notifications; return notifications;
} }
function getEsaUpdatedSection(policy: PolicyViewModel): string | null {
const marker = 'Updated section:';
const line = policy.content
.split('\n')
.map((item) => item.trim())
.find((item) => item.startsWith(marker));
if (!line) {
return null;
}
const sectionName = line.slice(marker.length).trim();
return sectionName.length > 0 ? sectionName : null;
}

View File

@ -0,0 +1,374 @@
import { useState, type ReactNode } from 'react';
import { Plus, Save, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
import type {
EsaFaqItem,
EsaFundingContent,
EsaParentConversationReference,
EsaResource,
EsaSchoolImpactItem,
EsaStaffRoleItem,
} from '@/shared/types/esaFunding';
interface EsaSectionEditorProps {
readonly content: EsaFundingContent;
readonly isSaving: boolean;
readonly onSave: (content: EsaFundingContent, sectionName: string) => Promise<void>;
}
interface InlineEditorShellProps {
readonly title: string;
readonly description: string;
readonly isSaving: boolean;
readonly onAdd?: () => void;
readonly onSave: () => Promise<void>;
readonly children: ReactNode;
}
function createId(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function textInputId(section: string, id: string, field: string): string {
return `esa-${section}-${id}-${field}`;
}
function InlineEditorShell({
title,
description,
isSaving,
onAdd,
onSave,
children,
}: InlineEditorShellProps) {
const [isOpen, setIsOpen] = useState(false);
async function submitForm() {
await onSave();
setIsOpen(false);
}
return (
<div className="mt-4 rounded-xl border border-slate-700/60 bg-slate-950/35">
<div className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-white">{title}</p>
<p className="text-xs text-slate-400">{description}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsOpen((current) => !current)}
leadingIcon={isOpen ? <X size={14} /> : undefined}
>
{isOpen ? 'Cancel' : 'Edit'}
</Button>
</div>
{isOpen ? (
<form
className="space-y-4 border-t border-slate-700/60 p-4"
onSubmit={(event) => {
event.preventDefault();
void submitForm();
}}
>
{onAdd ? (
<div className="flex justify-end">
<Button type="button" variant="ghost" size="sm" onClick={onAdd} leadingIcon={<Plus size={14} />}>
Add
</Button>
</div>
) : null}
<div className="space-y-3">{children}</div>
<div className="flex justify-end">
<Button type="submit" size="sm" loading={isSaving} leadingIcon={<Save size={14} />}>
Save section
</Button>
</div>
</form>
) : null}
</div>
);
}
function DeleteButton({ onClick }: { readonly onClick: () => void }) {
return (
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClick}
aria-label="Delete item"
className="text-rose-300 hover:text-rose-200"
>
<Trash2 size={16} />
</Button>
);
}
export function EsaFundingSchoolImpactEditor({
content,
isSaving,
onSave,
}: EsaSectionEditorProps) {
const [items, setItems] = useState<readonly EsaSchoolImpactItem[]>(content.schoolImpactItems);
function updateItem(id: string, text: string) {
setItems((current) => current.map((item) => (item.id === id ? { ...item, text } : item)));
}
function addItem() {
setItems((current) => [...current, { id: createId('impact'), text: '' }]);
}
function deleteItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
return (
<InlineEditorShell
title="Edit school impact"
description="Manage the points shown in this section."
isSaving={isSaving}
onAdd={addItem}
onSave={() => onSave({ ...content, schoolImpactItems: items }, 'Why This Matters for Our School')}
>
{items.map((item) => (
<div key={item.id} className="flex gap-2">
<Input
id={textInputId('impact', item.id, 'text')}
value={item.text}
onChange={(event) => updateItem(item.id, event.target.value)}
aria-label="School impact item"
/>
<DeleteButton onClick={() => deleteItem(item.id)} />
</div>
))}
</InlineEditorShell>
);
}
export function EsaFundingStaffRoleEditor({
content,
isSaving,
onSave,
}: EsaSectionEditorProps) {
const [items, setItems] = useState<readonly EsaStaffRoleItem[]>(content.staffRoleItems);
function updateItem(id: string, patch: Partial<EsaStaffRoleItem>) {
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
}
function addItem() {
setItems((current) => [...current, { id: createId('staff'), title: '', description: '' }]);
}
function deleteItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
return (
<InlineEditorShell
title="Edit staff guidance"
description="Manage the staff-only guidance shown here."
isSaving={isSaving}
onAdd={addItem}
onSave={() => onSave({ ...content, staffRoleItems: items }, 'Your Role as Staff')}
>
{items.map((item) => (
<div key={item.id} className="grid gap-2 rounded-xl border border-slate-700/50 p-3">
<Label htmlFor={textInputId('staff', item.id, 'title')}>Title</Label>
<Input
id={textInputId('staff', item.id, 'title')}
value={item.title}
onChange={(event) => updateItem(item.id, { title: event.target.value })}
/>
<Label htmlFor={textInputId('staff', item.id, 'description')}>Description</Label>
<Textarea
id={textInputId('staff', item.id, 'description')}
value={item.description}
onChange={(event) => updateItem(item.id, { description: event.target.value })}
/>
<DeleteButton onClick={() => deleteItem(item.id)} />
</div>
))}
</InlineEditorShell>
);
}
export function EsaFundingFaqEditor({
content,
isSaving,
onSave,
}: EsaSectionEditorProps) {
const [items, setItems] = useState<readonly EsaFaqItem[]>(content.faqs);
function updateItem(id: string, patch: Partial<EsaFaqItem>) {
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
}
function addItem() {
setItems((current) => [...current, { id: createId('faq'), question: '', answer: '', audience: 'all' }]);
}
function deleteItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
return (
<InlineEditorShell
title="Edit FAQ"
description="Manage questions and their student/guardian visibility."
isSaving={isSaving}
onAdd={addItem}
onSave={() => onSave({ ...content, faqs: items }, 'Frequently Asked Questions')}
>
{items.map((item) => (
<div key={item.id} className="grid gap-2 rounded-xl border border-slate-700/50 p-3">
<Label htmlFor={textInputId('faq', item.id, 'question')}>Question</Label>
<Input
id={textInputId('faq', item.id, 'question')}
value={item.question}
onChange={(event) => updateItem(item.id, { question: event.target.value })}
/>
<Label htmlFor={textInputId('faq', item.id, 'answer')}>Answer</Label>
<Textarea
id={textInputId('faq', item.id, 'answer')}
value={item.answer}
onChange={(event) => updateItem(item.id, { answer: event.target.value })}
/>
<Label htmlFor={textInputId('faq', item.id, 'audience')}>Audience</Label>
<NativeSelect
id={textInputId('faq', item.id, 'audience')}
value={item.audience}
onChange={(event) => updateItem(item.id, {
audience: event.target.value === 'staff' ? 'staff' : 'all',
})}
>
<option value="all">Show for everybody</option>
<option value="staff">Hide from students and guardians</option>
</NativeSelect>
<DeleteButton onClick={() => deleteItem(item.id)} />
</div>
))}
</InlineEditorShell>
);
}
export function EsaFundingQuickReferenceEditor({
content,
isSaving,
onSave,
}: EsaSectionEditorProps) {
const [items, setItems] = useState<readonly EsaParentConversationReference[]>(content.parentConversationReferences);
function updateItem(id: string, patch: Partial<EsaParentConversationReference>) {
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
}
function addItem() {
setItems((current) => [...current, {
id: createId('parent-reference'),
question: '',
answer: '',
}]);
}
function deleteItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
return (
<InlineEditorShell
title="Edit quick reference"
description="Manage the staff-only parent conversation references."
isSaving={isSaving}
onAdd={addItem}
onSave={() => onSave({ ...content, parentConversationReferences: items }, 'Quick Reference for Parent Conversations')}
>
{items.map((item, index) => (
<div key={item.id} className="grid gap-2 rounded-xl border border-slate-700/50 p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-slate-200">Parent conversation reference {index + 1}</p>
<DeleteButton onClick={() => deleteItem(item.id)} />
</div>
<Label htmlFor={textInputId('parent-reference', item.id, 'question')}>Question</Label>
<Input
id={textInputId('parent-reference', item.id, 'question')}
value={item.question}
onChange={(event) => updateItem(item.id, { question: event.target.value })}
placeholder="If a parent asks about ESA, here is a simple way to explain it:"
/>
<Label htmlFor={textInputId('parent-reference', item.id, 'answer')}>Answer</Label>
<Textarea
id={textInputId('parent-reference', item.id, 'answer')}
value={item.answer}
onChange={(event) => updateItem(item.id, { answer: event.target.value })}
placeholder="ESA stands for Empowerment Scholarship Account..."
/>
</div>
))}
</InlineEditorShell>
);
}
export function EsaFundingResourcesEditor({
content,
isSaving,
onSave,
}: EsaSectionEditorProps) {
const [items, setItems] = useState<readonly EsaResource[]>(content.resources);
function updateItem(id: string, patch: Partial<EsaResource>) {
setItems((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
}
function addItem() {
setItems((current) => [...current, { id: createId('resource'), title: '', description: '', url: '#' }]);
}
function deleteItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
return (
<InlineEditorShell
title="Edit resources"
description="Manage the links shown in this section."
isSaving={isSaving}
onAdd={addItem}
onSave={() => onSave({ ...content, resources: items }, 'Helpful Resources')}
>
{items.map((item) => (
<div key={item.id} className="grid gap-2 rounded-xl border border-slate-700/50 p-3">
<Label htmlFor={textInputId('resource', item.id, 'title')}>Title</Label>
<Input
id={textInputId('resource', item.id, 'title')}
value={item.title}
onChange={(event) => updateItem(item.id, { title: event.target.value })}
/>
<Label htmlFor={textInputId('resource', item.id, 'description')}>Description</Label>
<Input
id={textInputId('resource', item.id, 'description')}
value={item.description}
onChange={(event) => updateItem(item.id, { description: event.target.value })}
/>
<Label htmlFor={textInputId('resource', item.id, 'url')}>URL</Label>
<Input
id={textInputId('resource', item.id, 'url')}
value={item.url}
onChange={(event) => updateItem(item.id, { url: event.target.value })}
/>
<DeleteButton onClick={() => deleteItem(item.id)} />
</div>
))}
</InlineEditorShell>
);
}

View File

@ -1,4 +1,5 @@
import { ChevronDown, ChevronUp, HelpCircle } from 'lucide-react'; import { ChevronDown, ChevronUp, HelpCircle } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { EsaFaqItem } from '@/shared/types/esaFunding'; import type { EsaFaqItem } from '@/shared/types/esaFunding';
@ -8,14 +9,16 @@ interface EsaFundingFaqProps {
readonly items: readonly EsaFaqItem[]; readonly items: readonly EsaFaqItem[];
readonly expandedFAQ: number | null; readonly expandedFAQ: number | null;
readonly onToggleFAQ: (index: number) => void; readonly onToggleFAQ: (index: number) => void;
readonly editor?: ReactNode;
} }
export function EsaFundingFaq({ export function EsaFundingFaq({
items, items,
expandedFAQ, expandedFAQ,
onToggleFAQ, onToggleFAQ,
editor,
}: EsaFundingFaqProps) { }: EsaFundingFaqProps) {
if (items.length === 0) { if (items.length === 0 && !editor) {
return null; return null;
} }
@ -31,7 +34,7 @@ export function EsaFundingFaq({
return ( return (
<div <div
key={faq.question} key={faq.id}
className={cn( className={cn(
'bg-slate-800/60 backdrop-blur-sm rounded-xl border transition-all', 'bg-slate-800/60 backdrop-blur-sm rounded-xl border transition-all',
expanded ? 'border-emerald-500/30 shadow-lg shadow-emerald-500/5' : 'border-slate-700/50', expanded ? 'border-emerald-500/30 shadow-lg shadow-emerald-500/5' : 'border-slate-700/50',
@ -73,6 +76,7 @@ export function EsaFundingFaq({
); );
})} })}
</div> </div>
{editor}
</section> </section>
); );
} }

View File

@ -1,17 +1,22 @@
import { CheckCircle2, Info, Shield } from 'lucide-react'; import { CheckCircle2, Info, Shield } from 'lucide-react';
import type { ReactNode } from 'react';
import type { EsaStaffRoleItem } from '@/shared/types/esaFunding'; import type { EsaSchoolImpactItem, EsaStaffRoleItem } from '@/shared/types/esaFunding';
interface EsaFundingImpactRolesProps { interface EsaFundingImpactRolesProps {
readonly schoolImpactItems: readonly string[]; readonly schoolImpactItems: readonly EsaSchoolImpactItem[];
readonly staffRoleItems: readonly EsaStaffRoleItem[]; readonly staffRoleItems: readonly EsaStaffRoleItem[];
readonly schoolImpactEditor?: ReactNode;
readonly staffRoleEditor?: ReactNode;
} }
export function EsaFundingImpactRoles({ export function EsaFundingImpactRoles({
schoolImpactItems, schoolImpactItems,
staffRoleItems, staffRoleItems,
schoolImpactEditor,
staffRoleEditor,
}: EsaFundingImpactRolesProps) { }: EsaFundingImpactRolesProps) {
if (schoolImpactItems.length === 0 && staffRoleItems.length === 0) { if (schoolImpactItems.length === 0 && staffRoleItems.length === 0 && !schoolImpactEditor && !staffRoleEditor) {
return null; return null;
} }
@ -24,12 +29,13 @@ export function EsaFundingImpactRoles({
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{schoolImpactItems.map((item) => ( {schoolImpactItems.map((item) => (
<div key={item} className="flex items-start gap-2.5"> <div key={item.id} className="flex items-start gap-2.5">
<CheckCircle2 size={16} className="text-violet-400 flex-shrink-0 mt-0.5" /> <CheckCircle2 size={16} className="text-violet-400 flex-shrink-0 mt-0.5" />
<span className="text-sm text-slate-300 leading-relaxed">{item}</span> <span className="text-sm text-slate-300 leading-relaxed">{item.text}</span>
</div> </div>
))} ))}
</div> </div>
{schoolImpactEditor}
</div> </div>
<div className="bg-gradient-to-br from-amber-500/10 to-amber-600/5 rounded-2xl p-6 border border-amber-500/20"> <div className="bg-gradient-to-br from-amber-500/10 to-amber-600/5 rounded-2xl p-6 border border-amber-500/20">
@ -50,6 +56,7 @@ export function EsaFundingImpactRoles({
</div> </div>
))} ))}
</div> </div>
{staffRoleEditor}
</div> </div>
</section> </section>
); );

View File

@ -1,14 +1,16 @@
import { FileText, Info } from 'lucide-react'; import { FileText, Info } from 'lucide-react';
import type { ReactNode } from 'react';
import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding'; import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding';
import type { EsaFundingContent } from '@/shared/types/esaFunding'; import type { EsaFundingContent } from '@/shared/types/esaFunding';
interface EsaFundingQuickReferenceProps { interface EsaFundingQuickReferenceProps {
readonly content: EsaFundingContent; readonly content: EsaFundingContent;
readonly editor?: ReactNode;
} }
export function EsaFundingQuickReference({ content }: EsaFundingQuickReferenceProps) { export function EsaFundingQuickReference({ content, editor }: EsaFundingQuickReferenceProps) {
if (!content.parentConversationScript) { if (content.parentConversationReferences.length === 0 && !editor) {
return null; return null;
} }
@ -19,15 +21,22 @@ export function EsaFundingQuickReference({ content }: EsaFundingQuickReferencePr
<h3 className="font-bold text-white">Quick Reference for Parent Conversations</h3> <h3 className="font-bold text-white">Quick Reference for Parent Conversations</h3>
</div> </div>
<div className="bg-slate-900/60 rounded-xl p-5 border border-slate-700/40"> <div className="bg-slate-900/60 rounded-xl p-5 border border-slate-700/40">
<p className="text-sm text-slate-300 italic leading-relaxed mb-4">{ESA_FUNDING_STATIC_COPY.parentConversationIntro}</p> <div className="space-y-4">
<div className="bg-emerald-500/10 rounded-xl p-4 border border-emerald-500/20"> {content.parentConversationReferences.map((reference) => (
<p className="text-sm text-emerald-100 leading-relaxed">{content.parentConversationScript}</p> <div key={reference.id} className="space-y-4">
<p className="text-sm text-slate-300 italic leading-relaxed">{reference.question}</p>
<div className="bg-emerald-500/10 rounded-xl p-4 border border-emerald-500/20">
<p className="text-sm text-emerald-100 leading-relaxed">{reference.answer}</p>
</div>
</div>
))}
</div> </div>
<div className="mt-4 flex items-center gap-2 text-xs text-slate-500"> <div className="mt-4 flex items-center gap-2 text-xs text-slate-500">
<Info size={14} /> <Info size={14} />
<span>{ESA_FUNDING_STATIC_COPY.parentConversationFooter}</span> <span>{ESA_FUNDING_STATIC_COPY.parentConversationFooter}</span>
</div> </div>
</div> </div>
{editor}
</section> </section>
); );
} }

View File

@ -1,4 +1,5 @@
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import type { ReactNode } from 'react';
import { isValidEsaResourceUrl } from '@/business/esa-funding/selectors'; import { isValidEsaResourceUrl } from '@/business/esa-funding/selectors';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -7,10 +8,11 @@ import { cn } from '@/lib/utils';
interface EsaFundingResourcesProps { interface EsaFundingResourcesProps {
readonly resources: readonly EsaResource[]; readonly resources: readonly EsaResource[];
readonly editor?: ReactNode;
} }
export function EsaFundingResources({ resources }: EsaFundingResourcesProps) { export function EsaFundingResources({ resources, editor }: EsaFundingResourcesProps) {
if (resources.length === 0) { if (resources.length === 0 && !editor) {
return null; return null;
} }
@ -24,25 +26,25 @@ export function EsaFundingResources({ resources }: EsaFundingResourcesProps) {
{resources.map((resource) => { {resources.map((resource) => {
const validUrl = isValidEsaResourceUrl(resource.url); const validUrl = isValidEsaResourceUrl(resource.url);
const cardClassName = cn( const cardClassName = cn(
'bg-slate-900/60 rounded-xl p-4 border border-slate-700/40 transition-all text-left group h-auto items-start whitespace-normal', 'flex flex-col justify-start gap-1.5 bg-slate-900/60 rounded-xl p-4 border border-slate-700/40 transition-all text-left group h-auto items-start whitespace-normal',
validUrl ? 'hover:border-emerald-500/30' : 'opacity-70 cursor-not-allowed', validUrl ? 'hover:border-emerald-500/30' : 'opacity-70 cursor-not-allowed',
); );
const content = ( const content = (
<> <>
<span className="flex items-center justify-between mb-1.5 w-full"> <span className="flex w-full items-start justify-between gap-3">
<span className="text-sm font-semibold text-white group-hover:text-emerald-400 transition-colors"> <span className="text-sm font-semibold leading-snug text-white transition-colors group-hover:text-emerald-400">
{resource.title} {resource.title}
</span> </span>
<ExternalLink size={14} className="text-slate-600 group-hover:text-emerald-400 transition-colors" /> <ExternalLink size={14} className="mt-0.5 shrink-0 text-slate-600 transition-colors group-hover:text-emerald-400" />
</span> </span>
<span className="text-xs text-slate-500">{resource.description}</span> <span className="block text-xs leading-relaxed text-slate-500">{resource.description}</span>
{!validUrl && <span className="text-[10px] text-amber-400 mt-2 block">Link not configured</span>} {!validUrl && <span className="text-[10px] text-amber-400 mt-2 block">Link not configured</span>}
</> </>
); );
if (validUrl) { if (validUrl) {
return ( return (
<Button key={resource.title} asChild variant="ghost" className={cardClassName}> <Button key={resource.id} asChild variant="ghost" className={cardClassName}>
<a href={resource.url} target="_blank" rel="noopener noreferrer"> <a href={resource.url} target="_blank" rel="noopener noreferrer">
{content} {content}
</a> </a>
@ -51,12 +53,13 @@ export function EsaFundingResources({ resources }: EsaFundingResourcesProps) {
} }
return ( return (
<div key={resource.title} className={cardClassName} aria-disabled="true"> <div key={resource.id} className={cardClassName} aria-disabled="true">
{content} {content}
</div> </div>
); );
})} })}
</div> </div>
{editor}
</section> </section>
); );
} }

View File

@ -1,14 +1,13 @@
import { CheckCircle2, Info } from 'lucide-react'; import { CheckCircle2, Info } from 'lucide-react';
import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding'; import { ESA_FUNDING_STATIC_COPY } from '@/shared/constants/esaFunding';
import type { EsaFundingContent } from '@/shared/types/esaFunding';
interface EsaFundingStateNoticeProps { interface EsaFundingStateNoticeProps {
readonly content: EsaFundingContent; readonly items: readonly string[];
} }
export function EsaFundingStateNotice({ content }: EsaFundingStateNoticeProps) { export function EsaFundingStateNotice({ items }: EsaFundingStateNoticeProps) {
if (content.stateChecklist.length === 0) { if (items.length === 0) {
return null; return null;
} }
@ -24,7 +23,7 @@ export function EsaFundingStateNotice({ content }: EsaFundingStateNoticeProps) {
<div className="bg-slate-800/60 rounded-xl p-4 border border-slate-700/40 space-y-2"> <div className="bg-slate-800/60 rounded-xl p-4 border border-slate-700/40 space-y-2">
<p className="text-sm text-slate-200 font-semibold">{ESA_FUNDING_STATIC_COPY.stateNoticeChecklistTitle}</p> <p className="text-sm text-slate-200 font-semibold">{ESA_FUNDING_STATIC_COPY.stateNoticeChecklistTitle}</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{content.stateChecklist.map((item) => ( {items.map((item) => (
<div key={item} className="flex items-start gap-2"> <div key={item} className="flex items-start gap-2">
<CheckCircle2 size={14} className="text-amber-400 flex-shrink-0 mt-0.5" /> <CheckCircle2 size={14} className="text-amber-400 flex-shrink-0 mt-0.5" />
<span className="text-xs text-slate-400 leading-relaxed">{item}</span> <span className="text-xs text-slate-400 leading-relaxed">{item}</span>

View File

@ -3,6 +3,13 @@ import { AlertTriangle, Wallet } from 'lucide-react';
import type { EsaFundingPage } from '@/business/esa-funding/types'; import type { EsaFundingPage } from '@/business/esa-funding/types';
import { EsaFundingAcknowledgement } from '@/components/esa-funding/EsaFundingAcknowledgement'; import { EsaFundingAcknowledgement } from '@/components/esa-funding/EsaFundingAcknowledgement';
import { EsaFundingApprovedUses } from '@/components/esa-funding/EsaFundingApprovedUses'; import { EsaFundingApprovedUses } from '@/components/esa-funding/EsaFundingApprovedUses';
import {
EsaFundingFaqEditor,
EsaFundingQuickReferenceEditor,
EsaFundingResourcesEditor,
EsaFundingSchoolImpactEditor,
EsaFundingStaffRoleEditor,
} from '@/components/esa-funding/EsaFundingEditor';
import { EsaFundingFaq } from '@/components/esa-funding/EsaFundingFaq'; import { EsaFundingFaq } from '@/components/esa-funding/EsaFundingFaq';
import { EsaFundingHeader } from '@/components/esa-funding/EsaFundingHeader'; import { EsaFundingHeader } from '@/components/esa-funding/EsaFundingHeader';
import { EsaFundingHero } from '@/components/esa-funding/EsaFundingHero'; import { EsaFundingHero } from '@/components/esa-funding/EsaFundingHero';
@ -12,6 +19,11 @@ import { EsaFundingQuickReference } from '@/components/esa-funding/EsaFundingQui
import { EsaFundingResources } from '@/components/esa-funding/EsaFundingResources'; import { EsaFundingResources } from '@/components/esa-funding/EsaFundingResources';
import { EsaFundingStateNotice } from '@/components/esa-funding/EsaFundingStateNotice'; import { EsaFundingStateNotice } from '@/components/esa-funding/EsaFundingStateNotice';
import { StatePanel } from '@/components/ui/state-panel'; import { StatePanel } from '@/components/ui/state-panel';
import {
ESA_FUNDING_STATIC_APPROVED_USES,
ESA_FUNDING_STATIC_KEY_POINTS,
ESA_FUNDING_STATIC_STATE_CHECKLIST,
} from '@/shared/constants/esaFunding';
interface EsaFundingViewProps { interface EsaFundingViewProps {
readonly page: EsaFundingPage; readonly page: EsaFundingPage;
@ -50,27 +62,68 @@ export function EsaFundingView({ page }: EsaFundingViewProps) {
{!page.isLoading && !page.error && ( {!page.isLoading && !page.error && (
<> <>
<EsaFundingStateNotice content={page.content} /> <EsaFundingStateNotice items={ESA_FUNDING_STATIC_STATE_CHECKLIST} />
<EsaFundingKeyPoints items={page.content.keyPoints} /> <EsaFundingKeyPoints items={ESA_FUNDING_STATIC_KEY_POINTS} />
<EsaFundingApprovedUses items={page.content.approvedUses} /> <EsaFundingApprovedUses items={ESA_FUNDING_STATIC_APPROVED_USES} />
<EsaFundingImpactRoles <EsaFundingImpactRoles
schoolImpactItems={page.content.schoolImpactItems} schoolImpactItems={page.content.schoolImpactItems}
staffRoleItems={page.content.staffRoleItems} staffRoleItems={page.content.staffRoleItems}
schoolImpactEditor={page.canManage ? (
<EsaFundingSchoolImpactEditor
content={page.content}
isSaving={page.isSaving}
onSave={page.saveContent}
/>
) : undefined}
staffRoleEditor={page.canManage ? (
<EsaFundingStaffRoleEditor
content={page.content}
isSaving={page.isSaving}
onSave={page.saveContent}
/>
) : undefined}
/> />
<EsaFundingFaq <EsaFundingFaq
items={page.faqs} items={page.faqs}
expandedFAQ={page.expandedFAQ} expandedFAQ={page.expandedFAQ}
onToggleFAQ={page.toggleFAQ} onToggleFAQ={page.toggleFAQ}
editor={page.canManage ? (
<EsaFundingFaqEditor
content={page.content}
isSaving={page.isSaving}
onSave={page.saveContent}
/>
) : undefined}
/>
<EsaFundingQuickReference
content={page.content}
editor={page.canManage ? (
<EsaFundingQuickReferenceEditor
content={page.content}
isSaving={page.isSaving}
onSave={page.saveContent}
/>
) : undefined}
/>
<EsaFundingResources
resources={page.content.resources}
editor={page.canManage ? (
<EsaFundingResourcesEditor
content={page.content}
isSaving={page.isSaving}
onSave={page.saveContent}
/>
) : undefined}
/> />
<EsaFundingQuickReference content={page.content} />
<EsaFundingResources resources={page.content.resources} />
</> </>
)} )}
<EsaFundingAcknowledgement {page.canAcknowledge && (
acknowledged={page.acknowledged} <EsaFundingAcknowledgement
onToggle={page.toggleAcknowledged} acknowledged={page.acknowledged}
/> onToggle={page.toggleAcknowledged}
/>
)}
</div> </div>
); );
} }

View File

@ -10,8 +10,12 @@ const POLICY_DOCUMENTS_PATH = '/policy_documents';
export function listPolicyDocuments( export function listPolicyDocuments(
category: PolicyDocumentCategory, category: PolicyDocumentCategory,
filter?: { readonly tag?: string },
): Promise<ApiListResponse<PolicyDocumentDto>> { ): Promise<ApiListResponse<PolicyDocumentDto>> {
const query = new URLSearchParams({ category }).toString(); const query = new URLSearchParams({
category,
...(filter?.tag ? { tag: filter.tag } : {}),
}).toString();
return apiRequest<ApiListResponse<PolicyDocumentDto>>( return apiRequest<ApiListResponse<PolicyDocumentDto>>(
`${POLICY_DOCUMENTS_PATH}?${query}`, `${POLICY_DOCUMENTS_PATH}?${query}`,
); );

View File

@ -31,7 +31,8 @@ export const MODULE_PERMISSIONS = [
'READ_VOCATIONAL', 'READ_ESA', 'READ_WALKTHROUGH', 'READ_DIRECTOR_DASHBOARD', 'READ_VOCATIONAL', 'READ_ESA', 'READ_WALKTHROUGH', 'READ_DIRECTOR_DASHBOARD',
'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN', 'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN',
'MANAGE_FRAME', 'MANAGE_WALKTHROUGH', 'MANAGE_INTERNAL_COMM', 'MANAGE_FRAME', 'MANAGE_WALKTHROUGH', 'MANAGE_INTERNAL_COMM',
'MANAGE_CONTENT_CATALOG', 'READ_STAFF_ATTENDANCE_REPORTS', 'MANAGE_CONTENT_CATALOG', 'MANAGE_ESA_FUNDING_CONTENT',
'READ_STAFF_ATTENDANCE_REPORTS',
'READ_SAFETY_QUIZ_REPORTS', 'READ_PERSONALITY_REPORTS', 'READ_SAFETY_QUIZ_REPORTS', 'READ_PERSONALITY_REPORTS',
'READ_ZONE_CHECKIN_REPORTS', 'READ_POLICY_ACKNOWLEDGMENT_REPORTS', 'READ_ZONE_CHECKIN_REPORTS', 'READ_POLICY_ACKNOWLEDGMENT_REPORTS',
'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES', 'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES',

View File

@ -1,3 +1,5 @@
import type { EsaApprovedUse, EsaKeyPoint } from '@/shared/types/esaFunding';
export const ESA_FUNDING_STATIC_COPY = { export const ESA_FUNDING_STATIC_COPY = {
heroEyebrow: 'What is ESA?', heroEyebrow: 'What is ESA?',
heroTitle: heroTitle:
@ -16,45 +18,30 @@ export const ESA_FUNDING_STATIC_COPY = {
parentConversationFooter: 'For detailed questions, always direct families to the front office or administration.', parentConversationFooter: 'For detailed questions, always direct families to the front office or administration.',
} as const; } as const;
export const ESA_FUNDING_FAQS = [ export const ESA_FUNDING_STATIC_KEY_POINTS: readonly EsaKeyPoint[] = [
{ { iconId: 'arrow', label: 'Education money that follows the child' },
question: 'What is an ESA (Empowerment Scholarship Account)?', { iconId: 'school', label: 'Instead of staying with one school' },
answer: { iconId: 'users', label: 'Parents choose the best fit for their child' },
'An ESA is a state-funded account that provides education dollars directly to families. Instead of funding going only to a single public school, ESA money follows the child, allowing parents to choose the learning environment and services that best fit their child\'s unique needs.', { iconId: 'check', label: 'Covers tuition, therapies, tutoring & more' },
}, { iconId: 'star', label: 'More flexibility and control for families' },
{ { iconId: 'heart', label: 'Especially impactful for students with disabilities' },
question: 'Who is eligible for ESA funding?', ];
answer:
'Eligibility varies by state, but ESA programs typically prioritize students with disabilities including autism, students from low-income families, students in underperforming schools, foster children, and children of active-duty military. In many states, all K-12 students are now eligible.', export const ESA_FUNDING_STATIC_STATE_CHECKLIST: readonly string[] = [
}, 'Whether your state offers an ESA or similar school choice program',
{ 'What the specific eligibility requirements are in your state',
question: 'What can ESA funds be used for?', 'What expenses and services ESA funds can be applied toward in your state',
answer: 'The application process and deadlines for your state\'s program',
'ESA funds can be used for approved educational expenses including private school tuition, specialized therapies, tutoring services, curriculum and textbooks, educational technology, online learning programs, and other approved educational services.', 'Any reporting or documentation requirements unique to your state',
}, ];
{
question: 'How does ESA funding affect our school?', export const ESA_FUNDING_STATIC_APPROVED_USES: readonly EsaApprovedUse[] = [
answer: { iconId: 'school', title: 'Private School Tuition', description: 'Full or partial tuition at approved private schools, including autism-focused programs', color: 'from-violet-500 to-violet-600' },
'ESA funding is a positive opportunity for our school community. Families who choose our programs can use ESA funds to pay for tuition and specialized services we offer. This means more families can access the autism-focused education and therapies we provide, regardless of their financial situation.', { iconId: 'heart', title: 'Specialized Therapies', description: 'Speech therapy, occupational therapy, ABA, physical therapy, and counseling', color: 'from-pink-500 to-rose-600' },
}, { iconId: 'book', title: 'Tutoring Services', description: 'One-on-one or small group tutoring from approved educational providers', color: 'from-blue-500 to-blue-600' },
{ { iconId: 'puzzle', title: 'Curriculum & Materials', description: 'Textbooks, workbooks, educational software, and approved learning materials', color: 'from-amber-500 to-orange-600' },
question: 'How much funding does each student receive?', { iconId: 'graduation', title: 'Online Learning', description: 'Approved online courses, virtual tutoring, and digital learning platforms', color: 'from-emerald-500 to-green-600' },
answer: { iconId: 'users', title: 'Educational Services', description: 'Social skills groups, life skills training, vocational preparation, and more', color: 'from-cyan-500 to-teal-600' },
'The amount varies by state and student need. ESA amounts often range from several thousand dollars per year for general education students to higher amounts for students with disabilities who require specialized services and therapies.', ];
},
{ export const ESA_FUNDING_POLICY_TAG = 'ESA Funding';
question: 'How do families apply for ESA funding?',
answer:
'Families apply through their state ESA program, often through the Department of Education website. The process typically involves submitting an application, providing proof of eligibility, receiving approval, and directing funds to chosen educational providers.',
},
{
question: 'Can ESA funds be used for therapies at our school?',
answer:
'Yes. ESA funds can cover many specialized services, including speech therapy, occupational therapy, behavioral therapy, social skills groups, and other therapeutic interventions when those services are approved by the state program.',
},
{
question: 'What is our role as staff in the ESA process?',
answer:
'Staff should understand the basics, direct families to the office for detailed guidance, document student services accurately for ESA reporting, and continue providing individualized education regardless of how services are funded.',
},
] as const;

View File

@ -8,6 +8,7 @@ export const POLICY_DATE_NOT_RECORDED_LABEL = 'Not recorded';
export const POLICY_QUERY_KEYS = { export const POLICY_QUERY_KEYS = {
documents: ['policies', 'documents'], documents: ['policies', 'documents'],
safetyDocuments: ['policies', 'safety-documents'], safetyDocuments: ['policies', 'safety-documents'],
esaDocuments: ['policies', 'esa-documents'],
acknowledgments: ['policies', 'acknowledgments'], acknowledgments: ['policies', 'acknowledgments'],
acknowledgmentReport: ['policies', 'acknowledgment-report'], acknowledgmentReport: ['policies', 'acknowledgment-report'],
} as const; } as const;

View File

@ -10,4 +10,5 @@ export interface ContentCatalogMutationDto<TPayload> {
readonly payload: TPayload; readonly payload: TPayload;
readonly active?: boolean; readonly active?: boolean;
readonly importHash?: string | null; readonly importHash?: string | null;
readonly changeSummary?: string;
} }

View File

@ -10,8 +10,10 @@ export type EsaIconId =
| 'star'; | 'star';
export interface EsaFaqItem { export interface EsaFaqItem {
readonly id: string;
readonly question: string; readonly question: string;
readonly answer: string; readonly answer: string;
readonly audience: 'all' | 'staff';
} }
export interface EsaApprovedUse { export interface EsaApprovedUse {
@ -27,22 +29,33 @@ export interface EsaKeyPoint {
} }
export interface EsaStaffRoleItem { export interface EsaStaffRoleItem {
readonly id: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
} }
export interface EsaResource { export interface EsaResource {
readonly id: string;
readonly title: string; readonly title: string;
readonly description: string; readonly description: string;
readonly url: string; readonly url: string;
} }
export interface EsaSchoolImpactItem {
readonly id: string;
readonly text: string;
}
export interface EsaParentConversationReference {
readonly id: string;
readonly question: string;
readonly answer: string;
}
export interface EsaFundingContent { export interface EsaFundingContent {
readonly approvedUses: readonly EsaApprovedUse[]; readonly schoolImpactItems: readonly EsaSchoolImpactItem[];
readonly keyPoints: readonly EsaKeyPoint[];
readonly stateChecklist: readonly string[];
readonly schoolImpactItems: readonly string[];
readonly staffRoleItems: readonly EsaStaffRoleItem[]; readonly staffRoleItems: readonly EsaStaffRoleItem[];
readonly parentConversationScript: string; readonly faqs: readonly EsaFaqItem[];
readonly parentConversationReferences: readonly EsaParentConversationReference[];
readonly resources: readonly EsaResource[]; readonly resources: readonly EsaResource[];
} }