create-mithaq 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (396) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/openstatus-time-picker.txt +21 -0
  3. package/LICENSES/payload.txt +22 -0
  4. package/LICENSES/ponytail.txt +21 -0
  5. package/index.js +521 -0
  6. package/package.json +19 -4
  7. package/template/apps/asas-backoffice/components.json +25 -0
  8. package/template/apps/asas-backoffice/gitignore +3 -0
  9. package/template/apps/asas-backoffice/package.json +57 -0
  10. package/template/apps/asas-backoffice/public/favicon.svg +1 -0
  11. package/template/apps/asas-backoffice/src/api.ts +147 -0
  12. package/template/apps/asas-backoffice/src/auth-frame.tsx +150 -0
  13. package/template/apps/asas-backoffice/src/components/ui/accordion.tsx +74 -0
  14. package/template/apps/asas-backoffice/src/components/ui/alert-dialog.tsx +185 -0
  15. package/template/apps/asas-backoffice/src/components/ui/avatar.tsx +106 -0
  16. package/template/apps/asas-backoffice/src/components/ui/badge.tsx +48 -0
  17. package/template/apps/asas-backoffice/src/components/ui/breadcrumb.tsx +124 -0
  18. package/template/apps/asas-backoffice/src/components/ui/button-group.tsx +87 -0
  19. package/template/apps/asas-backoffice/src/components/ui/button.tsx +55 -0
  20. package/template/apps/asas-backoffice/src/components/ui/calendar.tsx +223 -0
  21. package/template/apps/asas-backoffice/src/components/ui/card.tsx +102 -0
  22. package/template/apps/asas-backoffice/src/components/ui/chart.tsx +371 -0
  23. package/template/apps/asas-backoffice/src/components/ui/checkbox.tsx +28 -0
  24. package/template/apps/asas-backoffice/src/components/ui/collapsible.tsx +19 -0
  25. package/template/apps/asas-backoffice/src/components/ui/combobox.tsx +308 -0
  26. package/template/apps/asas-backoffice/src/components/ui/dialog.tsx +158 -0
  27. package/template/apps/asas-backoffice/src/components/ui/direction.tsx +6 -0
  28. package/template/apps/asas-backoffice/src/components/ui/directional-icon.tsx +20 -0
  29. package/template/apps/asas-backoffice/src/components/ui/dropdown-menu.tsx +267 -0
  30. package/template/apps/asas-backoffice/src/components/ui/field.tsx +236 -0
  31. package/template/apps/asas-backoffice/src/components/ui/input-group.tsx +155 -0
  32. package/template/apps/asas-backoffice/src/components/ui/input-otp.tsx +84 -0
  33. package/template/apps/asas-backoffice/src/components/ui/input.tsx +19 -0
  34. package/template/apps/asas-backoffice/src/components/ui/label.tsx +19 -0
  35. package/template/apps/asas-backoffice/src/components/ui/popover.tsx +90 -0
  36. package/template/apps/asas-backoffice/src/components/ui/radio-group.tsx +37 -0
  37. package/template/apps/asas-backoffice/src/components/ui/resizable.tsx +47 -0
  38. package/template/apps/asas-backoffice/src/components/ui/scroll-area.tsx +54 -0
  39. package/template/apps/asas-backoffice/src/components/ui/select.tsx +204 -0
  40. package/template/apps/asas-backoffice/src/components/ui/separator.tsx +24 -0
  41. package/template/apps/asas-backoffice/src/components/ui/sheet.tsx +139 -0
  42. package/template/apps/asas-backoffice/src/components/ui/sidebar.tsx +714 -0
  43. package/template/apps/asas-backoffice/src/components/ui/skeleton.tsx +13 -0
  44. package/template/apps/asas-backoffice/src/components/ui/slider.tsx +55 -0
  45. package/template/apps/asas-backoffice/src/components/ui/spinner.tsx +10 -0
  46. package/template/apps/asas-backoffice/src/components/ui/switch.tsx +29 -0
  47. package/template/apps/asas-backoffice/src/components/ui/table.tsx +115 -0
  48. package/template/apps/asas-backoffice/src/components/ui/tabs.tsx +79 -0
  49. package/template/apps/asas-backoffice/src/components/ui/textarea.tsx +17 -0
  50. package/template/apps/asas-backoffice/src/components/ui/toggle-group.tsx +89 -0
  51. package/template/apps/asas-backoffice/src/components/ui/toggle.tsx +44 -0
  52. package/template/apps/asas-backoffice/src/components/ui/tooltip.tsx +63 -0
  53. package/template/apps/asas-backoffice/src/components.ts +35 -0
  54. package/template/apps/asas-backoffice/src/copy.ts +1 -0
  55. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  56. package/template/apps/asas-backoffice/src/hooks/use-mobile.ts +19 -0
  57. package/template/apps/asas-backoffice/src/lib/utils.ts +1 -0
  58. package/template/apps/asas-backoffice/src/links.ts +38 -0
  59. package/template/apps/asas-backoffice/src/locale-switch.tsx +28 -0
  60. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  61. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  62. package/template/apps/asas-backoffice/src/request-headers.ts +11 -0
  63. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  64. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  65. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  66. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/screen.json +3971 -0
  67. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/types.ts +163 -0
  68. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.$.tsx +17 -0
  69. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.index.tsx +10 -0
  70. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.tsx +13 -0
  71. package/template/apps/asas-backoffice/src/routes/__root.tsx +95 -0
  72. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  73. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  74. package/template/apps/asas-backoffice/src/routes/auth.sign-in.tsx +77 -0
  75. package/template/apps/asas-backoffice/src/routes/unauthorized.tsx +134 -0
  76. package/template/apps/asas-backoffice/src/screen-form.tsx +36 -0
  77. package/template/apps/asas-backoffice/src/screen-list.tsx +140 -0
  78. package/template/apps/asas-backoffice/src/screen-names.ts +10 -0
  79. package/template/apps/asas-backoffice/src/screens.tsx +888 -0
  80. package/template/apps/asas-backoffice/src/server.ts +30 -0
  81. package/template/apps/asas-backoffice/src/slide.ts +22 -0
  82. package/template/apps/asas-backoffice/src/start.ts +42 -0
  83. package/template/apps/asas-backoffice/src/styles.css +202 -0
  84. package/template/apps/asas-backoffice/src/theme.ts +2 -0
  85. package/template/apps/asas-backoffice/tsconfig.json +19 -0
  86. package/template/apps/asas-backoffice/vite.config.ts +32 -0
  87. package/template/apps/asas-tanstack/components.json +25 -0
  88. package/template/apps/asas-tanstack/gitignore +3 -0
  89. package/template/apps/asas-tanstack/package.json +58 -0
  90. package/template/apps/asas-tanstack/public/favicon.svg +1 -0
  91. package/template/apps/asas-tanstack/src/api.ts +147 -0
  92. package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
  93. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  94. package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
  95. package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
  96. package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
  97. package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
  98. package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
  99. package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
  100. package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
  101. package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
  102. package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
  103. package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
  104. package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
  105. package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
  106. package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
  107. package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
  108. package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
  109. package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
  110. package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
  111. package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
  112. package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
  113. package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
  114. package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
  115. package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
  116. package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
  117. package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
  118. package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
  119. package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
  120. package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
  121. package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
  122. package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
  123. package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
  124. package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
  125. package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
  126. package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
  127. package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
  128. package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
  129. package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
  130. package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
  131. package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
  132. package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
  133. package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
  134. package/template/apps/asas-tanstack/src/components.ts +35 -0
  135. package/template/apps/asas-tanstack/src/copy.ts +1207 -0
  136. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  137. package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
  138. package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
  139. package/template/apps/asas-tanstack/src/links.ts +38 -0
  140. package/template/apps/asas-tanstack/src/locale-switch.tsx +28 -0
  141. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  142. package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
  143. package/template/apps/asas-tanstack/src/redirect.ts +39 -0
  144. package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
  145. package/template/apps/asas-tanstack/src/routeTree.gen.ts +424 -0
  146. package/template/apps/asas-tanstack/src/router.tsx +18 -0
  147. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +11 -0
  148. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +4242 -0
  149. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +160 -0
  150. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
  151. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
  152. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
  153. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +9 -0
  154. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1564 -0
  155. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +94 -0
  156. package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
  157. package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
  158. package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
  159. package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
  160. package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
  161. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +108 -0
  162. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  163. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
  164. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
  165. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +16 -0
  166. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  167. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +98 -0
  168. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +143 -0
  169. package/template/apps/asas-tanstack/src/routes/index.tsx +146 -0
  170. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +134 -0
  171. package/template/apps/asas-tanstack/src/screen-form.tsx +36 -0
  172. package/template/apps/asas-tanstack/src/screen-list.tsx +140 -0
  173. package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
  174. package/template/apps/asas-tanstack/src/screens.tsx +888 -0
  175. package/template/apps/asas-tanstack/src/server.ts +26 -0
  176. package/template/apps/asas-tanstack/src/slide.ts +22 -0
  177. package/template/apps/asas-tanstack/src/start.ts +57 -0
  178. package/template/apps/asas-tanstack/src/styles.css +163 -0
  179. package/template/apps/asas-tanstack/src/theme.ts +5 -0
  180. package/template/apps/asas-tanstack/tsconfig.json +19 -0
  181. package/template/apps/asas-tanstack/vite.config.ts +34 -0
  182. package/template/package.json +16 -0
  183. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  184. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  185. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  186. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  187. package/template/templates/asas/.env.example +33 -0
  188. package/template/templates/asas/AGENTS.md +101 -0
  189. package/template/templates/asas/CLAUDE.md +1 -0
  190. package/template/templates/asas/README.md +1056 -0
  191. package/template/templates/asas/mithaq.hints.json +25 -0
  192. package/template/templates/asas/package.json +53 -0
  193. package/template/templates/asas/src/asas.ts +118 -0
  194. package/template/templates/asas/src/backoffice-http.ts +117 -0
  195. package/template/templates/asas/src/dev-setup.ts +112 -0
  196. package/template/templates/asas/src/local-database.ts +22 -0
  197. package/template/templates/asas/src/messages/account.json +132 -0
  198. package/template/templates/asas/src/messages/auditLog.json +304 -0
  199. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  200. package/template/templates/asas/src/messages/courses.json +312 -0
  201. package/template/templates/asas/src/messages/enrollments.json +60 -0
  202. package/template/templates/asas/src/messages/invitation.json +82 -0
  203. package/template/templates/asas/src/messages/localUsers.json +48 -0
  204. package/template/templates/asas/src/messages/media.json +76 -0
  205. package/template/templates/asas/src/messages/member.json +62 -0
  206. package/template/templates/asas/src/messages/memberRoles.json +48 -0
  207. package/template/templates/asas/src/messages/mithaq.json +214 -0
  208. package/template/templates/asas/src/messages/organization.json +72 -0
  209. package/template/templates/asas/src/messages/organizationRole.json +62 -0
  210. package/template/templates/asas/src/messages/roles.json +72 -0
  211. package/template/templates/asas/src/messages/session.json +102 -0
  212. package/template/templates/asas/src/messages/sessions.json +136 -0
  213. package/template/templates/asas/src/messages/settings.json +64 -0
  214. package/template/templates/asas/src/messages/tags.json +48 -0
  215. package/template/templates/asas/src/messages/user.json +112 -0
  216. package/template/templates/asas/src/messages/verification.json +62 -0
  217. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
  218. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
  219. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
  220. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
  221. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
  222. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
  223. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
  224. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
  225. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
  226. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
  227. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
  228. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
  229. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
  230. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
  231. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
  232. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
  233. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
  234. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
  235. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
  236. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
  237. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
  238. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
  239. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
  240. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
  241. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
  242. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
  243. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
  244. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
  245. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
  246. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
  247. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
  248. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
  249. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
  250. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
  251. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
  252. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
  253. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
  254. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
  255. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
  256. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
  257. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  258. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  259. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  260. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  261. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  262. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  263. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  264. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  265. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  266. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  267. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  268. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  269. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  270. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  271. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  272. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  273. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  274. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  275. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  276. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  277. package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
  278. package/template/templates/asas/src/migrations/schema.ts +30 -0
  279. package/template/templates/asas/src/mithaq.ts +4 -0
  280. package/template/templates/asas/src/organizations.ts +3 -0
  281. package/template/templates/asas/src/seed.ts +427 -0
  282. package/template/templates/asas/src/ui-backoffice/home.tsx +63 -0
  283. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  284. package/template/templates/asas/src/ui-backoffice/record-page.tsx +30 -0
  285. package/template/templates/asas/src/ui-backoffice/role-cells.tsx +59 -0
  286. package/template/templates/asas/src/ui-backoffice/user-actions.tsx +249 -0
  287. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +300 -0
  288. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  289. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  290. package/template/templates/asas/src/ui-dashboard/members.tsx +829 -0
  291. package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
  292. package/template/templates/asas/src/ui-dashboard/versions.tsx +446 -0
  293. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  294. package/template/templates/asas/src/ui-portal/home.tsx +73 -0
  295. package/template/templates/asas/src/ui-portal/page.tsx +11 -0
  296. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  297. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  298. package/template/templates/features/audit-log/index.ts +4 -0
  299. package/template/templates/features/audit-log/package.json +13 -0
  300. package/template/templates/features/auth/index.ts +11 -0
  301. package/template/templates/features/auth/local-users/local-users.ts +143 -0
  302. package/template/templates/features/auth/package.json +13 -0
  303. package/template/templates/features/auth/user/account.access.ts +8 -0
  304. package/template/templates/features/auth/user/account.ts +31 -0
  305. package/template/templates/features/auth/user/index.ts +18 -0
  306. package/template/templates/features/auth/user/invitation.access.ts +4 -0
  307. package/template/templates/features/auth/user/invitation.ts +27 -0
  308. package/template/templates/features/auth/user/member.access.ts +10 -0
  309. package/template/templates/features/auth/user/member.screens.ts +37 -0
  310. package/template/templates/features/auth/user/member.ts +26 -0
  311. package/template/templates/features/auth/user/organization.access.ts +20 -0
  312. package/template/templates/features/auth/user/organization.screens.ts +37 -0
  313. package/template/templates/features/auth/user/organization.ts +25 -0
  314. package/template/templates/features/auth/user/organizationRole.access.ts +8 -0
  315. package/template/templates/features/auth/user/organizationRole.ts +25 -0
  316. package/template/templates/features/auth/user/session.access.ts +8 -0
  317. package/template/templates/features/auth/user/session.ts +28 -0
  318. package/template/templates/features/auth/user/user.access.ts +30 -0
  319. package/template/templates/features/auth/user/user.screens.ts +45 -0
  320. package/template/templates/features/auth/user/user.ts +31 -0
  321. package/template/templates/features/auth/user/verification.access.ts +8 -0
  322. package/template/templates/features/auth/user/verification.ts +23 -0
  323. package/template/templates/features/courses/courses/courses.ts +601 -0
  324. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  325. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  326. package/template/templates/features/courses/courses/ui/fee-cell.tsx +19 -0
  327. package/template/templates/features/courses/courses/ui/no-courses.tsx +13 -0
  328. package/template/templates/features/courses/courses/ui/status-cell.tsx +33 -0
  329. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  330. package/template/templates/features/courses/domain/archive.ts +39 -0
  331. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  332. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  333. package/template/templates/features/courses/enrollments/ui/session-cell.tsx +75 -0
  334. package/template/templates/features/courses/enrollments/ui/session-labels.tsx +36 -0
  335. package/template/templates/features/courses/index.ts +8 -0
  336. package/template/templates/features/courses/package.json +24 -0
  337. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  338. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  339. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  340. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  341. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  342. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  343. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  344. package/template/templates/features/courses/sessions/seats.ts +42 -0
  345. package/template/templates/features/courses/sessions/sessions.ts +186 -0
  346. package/template/templates/features/media/index.ts +10 -0
  347. package/template/templates/features/media/media/media.ts +131 -0
  348. package/template/templates/features/media/media/ui/preview.tsx +143 -0
  349. package/template/templates/features/media/package.json +19 -0
  350. package/template/templates/features/roles/index.ts +4 -0
  351. package/template/templates/features/roles/member-roles/member-roles.ts +207 -0
  352. package/template/templates/features/roles/package.json +21 -0
  353. package/template/templates/features/roles/roles/roles.ts +184 -0
  354. package/template/templates/features/roles/roles/ui/role-form.tsx +267 -0
  355. package/template/templates/features/roles/roles/ui/roles.tsx +190 -0
  356. package/template/templates/features/settings/index.ts +3 -0
  357. package/template/templates/features/settings/package.json +13 -0
  358. package/template/templates/features/settings/settings/settings.ts +64 -0
  359. package/template/templates/features/tags/index.ts +3 -0
  360. package/template/templates/features/tags/package.json +14 -0
  361. package/template/templates/features/tags/tags/tags.ts +80 -0
  362. package/template/templates/mithaq/components.json +22 -0
  363. package/template/templates/mithaq/package.json +33 -0
  364. package/template/templates/mithaq/src/mail/index.ts +32 -0
  365. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  366. package/template/templates/mithaq/src/mithaq/audit-sink.ts +111 -0
  367. package/template/templates/mithaq/src/mithaq/auth/backoffice-roles.ts +108 -0
  368. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  369. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  370. package/template/templates/mithaq/src/mithaq/pages.ts +83 -0
  371. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  372. package/template/templates/mithaq/src/mithaq.ts +593 -0
  373. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  374. package/template/templates/mithaq/src/roles/grants.ts +47 -0
  375. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  376. package/template/templates/mithaq/src/roles/statements.ts +13 -0
  377. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  378. package/template/templates/mithaq/src/roles/tenant-access.ts +260 -0
  379. package/template/templates/mithaq/src/roles/tenant-roles.ts +321 -0
  380. package/template/templates/mithaq/src/steps/expected-updated-at.ts +41 -0
  381. package/template/templates/mithaq/src/storage/defaults.ts +156 -0
  382. package/template/templates/mithaq/src/storage/index.ts +97 -0
  383. package/template/templates/mithaq/src/storage/local.ts +143 -0
  384. package/template/templates/mithaq/src/storage/r2.ts +5 -0
  385. package/template/templates/mithaq/src/storage/s3.ts +91 -0
  386. package/template/templates/mithaq/src/storage/uploadthing.ts +59 -0
  387. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  388. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  389. package/template/templates/mithaq/src/ui/app-shell.tsx +268 -0
  390. package/template/templates/mithaq/src/ui/kpi-card.tsx +25 -0
  391. package/template/templates/mithaq/src/ui/record-page.tsx +50 -0
  392. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  393. package/template/templates/mithaq/src/ui/screen-nav.tsx +28 -0
  394. package/template/templates/mithaq/src/ui/table-page.tsx +9 -0
  395. package/template/templates/mithaq/src/ui/top-shell.tsx +60 -0
  396. package/README.md +0 -3
@@ -0,0 +1,19 @@
1
+ // A row with no currency, one the reader may not read, or one Intl does not know keeps the plain
2
+ // number. The amount is a left-to-right isolate so Arabic neither reads "US$" as "$US" nor strands
3
+ // the dot of "ر.س." on the wrong side.
4
+ import type { CellProps } from 'mithaq/admin'
5
+ import { useLocale } from 'mithaq/admin/react'
6
+ import { noValue } from 'mithaq-admin/format.tsx'
7
+
8
+ const amount = (text: string) => <bdi dir="ltr">{text}</bdi>
9
+
10
+ export function FeeCell({ value, row }: CellProps) {
11
+ const { locale } = useLocale()
12
+ if (typeof value !== 'number') return noValue
13
+ try {
14
+ if (typeof row.currency === 'string' && row.currency) return amount(new Intl.NumberFormat(locale, { style: 'currency', currency: row.currency }).format(value))
15
+ } catch {
16
+ // A code Intl refuses throws a RangeError; the number alone is still true.
17
+ }
18
+ return amount(value.toLocaleString(locale))
19
+ }
@@ -0,0 +1,13 @@
1
+ // What the courses list draws when it holds no row, and what the board page
2
+ // draws by path: the same file under a registered name and an inline path.
3
+ import type { EmptyProps } from 'mithaq/admin'
4
+ import { useKayan, useLocale } from 'mithaq/admin/react'
5
+
6
+ export function NoCourses({ page }: EmptyProps) {
7
+ const { t } = useLocale()
8
+ return (
9
+ <p data-empty={`${useKayan().name}${page.path}`} className="text-muted-foreground text-sm">
10
+ {t('courses.none', 'No courses yet.')}
11
+ </p>
12
+ )
13
+ }
@@ -0,0 +1,33 @@
1
+ // The list reads each row's latest draft, so a published course whose newest version is a draft
2
+ // and a never-published course both arrive as `draft`. The plain read finds only courses with a
3
+ // published copy, which tells the two apart. An archived course says so over any of the three.
4
+ import { use } from 'react'
5
+ import { useQuery } from '@tanstack/react-query'
6
+ import type { CellProps, Row } from 'mithaq/admin'
7
+ import { ListContext, useClient, useLocale, useScreen } from 'mithaq/admin/react'
8
+ import { Badge } from '@/components/ui/badge'
9
+ import { noValue } from 'mithaq-admin/format.tsx'
10
+ import { coursesOf } from '../../enrollments/ui/session-cell.tsx'
11
+
12
+ const pills = {
13
+ draft: 'bg-muted text-muted-foreground',
14
+ published: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
15
+ changed: 'bg-amber-500/15 text-amber-700 dark:text-amber-400',
16
+ archived: 'bg-destructive/10 text-destructive',
17
+ }
18
+ const words = { draft: 'Draft', published: 'Published', changed: 'Changed', archived: 'Archived' }
19
+
20
+ export function StatusCell({ value, row }: CellProps) {
21
+ const client = useClient()
22
+ const { t } = useLocale()
23
+ const { maxLimit } = useScreen()
24
+ // Sorted, so the same rows in another order are the same query.
25
+ const ids = [...new Set((use(ListContext)?.page.docs ?? [row]).flatMap((one: Row) => (one._status === 'draft' ? [String(one.id)] : [])))].sort()
26
+ const { data: published, isError } = useQuery({ queryKey: ['status-cell', ids], queryFn: () => coursesOf({ client, ids, maxLimit }), enabled: ids.length > 0, staleTime: 30_000 })
27
+ if (value !== 'draft' && value !== 'published') return noValue
28
+ if (row.archived === true) return <Badge data-status="archived" className={pills.archived}>{t('version.archived', words.archived)}</Badge>
29
+ // Loading until the read answers, and a draft where it failed, which is what the row itself holds.
30
+ if (value === 'draft' && !published && !isError) return t('admin.loading', 'Loading…')
31
+ const status = value === 'draft' && published?.has(String(row.id)) ? 'changed' : value
32
+ return <Badge data-status={status} className={pills[status]}>{t(`version.${status}`, words[status])}</Badge>
33
+ }
@@ -0,0 +1,15 @@
1
+ // The danger zone's Unpublish: one click, behind the page's confirm, sending the
2
+ // draft status `unpublish` takes, as Payload's Unpublish does. The course stays
3
+ // as it is, read by draft readers alone, until it is published again.
4
+ import type { ButtonProps } from 'mithaq/admin'
5
+ import { useLocale } from 'mithaq/admin/react'
6
+ import { Button } from '@/components/ui/button'
7
+
8
+ export function UnpublishButton({ busy, operation, run }: ButtonProps) {
9
+ const { t } = useLocale()
10
+ return (
11
+ <Button type="button" variant="outline" data-button={operation} disabled={busy} onClick={() => void run({ _status: 'draft' })}>
12
+ {t('courses.operations.unpublish', 'Unpublish')}
13
+ </Button>
14
+ )
15
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod'
2
+ import type { Ctx } from 'mithaq'
3
+
4
+ /** The box an archive's or an unarchive's dialog asks for on every screen that draws its button. */
5
+ export const reason = {
6
+ kind: 'text',
7
+ label: { en: 'Reason', ar: 'السبب' },
8
+ rules: { required: true, maxLength: 200 },
9
+ } as const
10
+
11
+ /**
12
+ * What `archive` and `unarchive` parse: the row and why. The error messages are
13
+ * the kayan's own copy keys, which the form resolves; without them it draws
14
+ * what zod wrote, in English.
15
+ */
16
+ export const reasonInput = (key: `${string}.operations.${'archive' | 'unarchive'}`) => z.object({
17
+ id: z.guid(),
18
+ reason: z
19
+ .string({ error: `${key}.reason.required` })
20
+ .max(200, { error: `${key}.reason.maxLength` }),
21
+ })
22
+
23
+ /**
24
+ * The one step each adds: it turns the operation's `reason` into the two
25
+ * columns the kayan has, so `reason` never has to be a field. `ctx.data` is
26
+ * spread to keep what the runner filled before it, such as `updatedAt`, and
27
+ * `reason` reaches no column: the store step writes the kayan's own fields alone.
28
+ * An empty reason, which a bulk archive sends, keeps none.
29
+ */
30
+ export const marks = (archived: boolean) => (ctx: Ctx<{ reason: string }>): Ctx => ({ ...ctx, data: { ...ctx.data, archived, archiveReason: ctx.data.reason || null } })
31
+
32
+ /** Over a row not archived yet, where an archive button is drawn. Both arms, since a row from before the column holds `null`. */
33
+ export const unarchived = { or: [{ 'record.archived': { exists: false } }, { 'record.archived': { equals: false } }] } as const
34
+
35
+ /** Over an archived row, where an unarchive button and the Archived badge are drawn. */
36
+ export const archivedRow = { 'record.archived': { equals: true } } as const
37
+
38
+ /** A list's tabs over the same column: what is running, what was archived, and everything. */
39
+ export const notArchived = { or: [{ archived: { exists: false } }, { archived: { equals: false } }] } as const
@@ -0,0 +1,6 @@
1
+ // The courses the portal offers: every course not archived, of those a plain
2
+ // read answers, which are the published ones. The portal's catalogue, its home
3
+ // and its course page read these alone. Plain data in a file of its own, so a
4
+ // page reads it without the kayan's declaration. A course with no `archived`
5
+ // value is not archived either.
6
+ export const onOffer = { or: [{ archived: { exists: false } }, { archived: { equals: false } }] }
@@ -0,0 +1,309 @@
1
+ import { conflict, defaultOperations, folderOf, invalid, type Ctx, type FilterOptions, type WriteTools } from 'mithaq'
2
+ import { defineKayan, organization, serverTargets } from '@asas/mithaq'
3
+ import { currentOrganization, readsPast, user as actor } from '@asas/mithaq/roles/tenant-access.ts'
4
+ import { activity, appPage, danger, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
5
+ import { sessions } from '../sessions/sessions.ts'
6
+ import { holdSeats, seatsTaken, sessionHeld } from '../sessions/seats.ts'
7
+ // add-on: settings
8
+ import { sql } from 'mithaq/drizzle'
9
+ import type { NodePgDatabase } from 'mithaq/drizzle/node-postgres'
10
+ // end add-on: settings
11
+
12
+ const ui = folderOf(new URL('./ui/', import.meta.url))
13
+ const portalUi = folderOf(new URL('../portal/ui/', import.meta.url))
14
+ /** A portal page in its shell, which draws the portal's own nav, over its single column. */
15
+ const portalPage = <const Main extends readonly unknown[]>(main: Main) => ({ shell: 'top', header: [{ title: {} }], main: [{ layout: 'page', main }] }) as const
16
+ const sessionCell = ui('session-cell.tsx#SessionCell')
17
+ const enrollment = [{ section: 'enrollment', label: { en: 'Enrollment', ar: 'التسجيل' }, fields: ['session', 'student'] }] as const
18
+
19
+ /**
20
+ * The seats an actor reads, edits and deletes, inside its active organization:
21
+ * every seat of its organization for an actor holding
22
+ * `enrollments.others` and for backoffice staff, its own seats alone for anyone
23
+ * else. That is the student, whose enrollment keys would otherwise reach every
24
+ * other student's seat, and a custom role a tenant writes without the key.
25
+ * Another's seat answers 404, as a missing row does.
26
+ *
27
+ * The id is read off the actor as an own key, and an actor carrying none reaches
28
+ * no seat. Every read answers from it, a session's join of its enrollments
29
+ * included.
30
+ */
31
+ const ownId = (user: unknown) => {
32
+ const actor = user && typeof user === 'object' ? (user as Record<string, unknown>) : undefined
33
+ const id = actor && Object.hasOwn(actor, 'id') ? actor.id : undefined
34
+ return typeof id === 'string' && id ? id : undefined
35
+ }
36
+ const ownSeats = ({ user }: { user: unknown }) => {
37
+ if (readsPast(user, 'enrollments.others')) return true
38
+ const id = ownId(user)
39
+ return id ? { student: { equals: id } } : false
40
+ }
41
+
42
+ /**
43
+ * Whether a write names another person as a seat's student for an actor that
44
+ * writes its own seats alone. Refused on a create and on an update, so a seat
45
+ * is never made for, or handed to, someone the actor may not write for.
46
+ */
47
+ const namesAnother = ({ user, data }: { user: unknown; data?: unknown }) =>
48
+ !readsPast(user, 'enrollments.others') && !!data && typeof data === 'object' && Object.hasOwn(data, 'student')
49
+ && (data as { student?: unknown }).student !== ownId(user)
50
+
51
+ /**
52
+ * Whom a seat's student picker offers: the people holding the student role in
53
+ * the active organization, a teacher who also studies among them. It narrows
54
+ * what the actor's own read of people answers and never widens it, so an
55
+ * instructor is offered the students it already reads and not itself.
56
+ */
57
+ const students: FilterOptions = async ({ user, tx }) => {
58
+ const active = currentOrganization(user)
59
+ if (!active) return false
60
+ const held = await serverTargets.drizzle.find({ tx, kayan: 'memberRoles', args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' }, where: { organization: { equals: active }, role: { equals: 'student' } } } })
61
+ const ids = held.docs.map((row) => row.user).filter((id): id is string => typeof id === 'string')
62
+ return ids.length ? { id: { in: ids } } : false
63
+ }
64
+
65
+ /** Not archived, the column read by its name, or across a session's course. */
66
+ const open = (path: string) => ({ or: [{ [path]: { exists: false } }, { [path]: { equals: false } }] })
67
+
68
+ /** What a seat's session picker offers, and a save is held to: a session open for seats, of a course open too. */
69
+ const openSessions: FilterOptions = () => ({ and: [open('archived'), open('course.archived')] })
70
+
71
+ /** Whether the actor writes its own seats alone: a student, held to what each course lets its students do. */
72
+ const ownSeatsOnly = (user: unknown) => !readsPast(user, 'enrollments.others')
73
+
74
+ /** Whether a session is still to start, which a student's enrollment and its cancel both ask. */
75
+ const yetToStart = (startsAt: unknown) => new Date(startsAt as string | Date).getTime() > Date.now()
76
+
77
+ /**
78
+ * Whether a session takes this seat. A session the actor may not read is left
79
+ * to the reference check, which refuses it in the words a missing one gets.
80
+ * The rest is judged on the session's own columns, whatever the actor is shown:
81
+ * an archived session, or one of an archived course, takes none; a student
82
+ * takes a seat only where its course lets students enroll themselves, and only
83
+ * before the session starts; and a session with a capacity takes no seat past
84
+ * it, whoever writes, counted under a lock on the session. A seat kept in the
85
+ * session it already holds takes no new one.
86
+ */
87
+ const takesSeat = async (ctx: Ctx<{ session?: unknown }>, tools: WriteTools): Promise<Ctx> => {
88
+ const id = ctx.data.session
89
+ // An edit of a seat the actor may not reach loaded no row; the store answers it the 404 a missing one gets.
90
+ if (typeof id !== 'string' || (tools.operation !== 'create' && !ctx.dataBefore)) return ctx
91
+ if (!(await tools.of(sessions).findByID({ id }).catch(() => null))) return ctx
92
+ const session = await sessionHeld({ tx: ctx.tx, session: id })
93
+ if (!session) return ctx
94
+ // The stored id from here on: a uuid matches in any case, and the lock, the count and the comparison below must not
95
+ // tell `A0EE…` from `a0ee…`.
96
+ const held = session.id
97
+ if (session.archived || session.courseArchived) {
98
+ throw invalid({
99
+ message: 'This session is archived, or its course is, so it takes no new seat.',
100
+ key: 'error.invalid.archived_session',
101
+ params: { kayan: 'enrollments', field: 'session' },
102
+ })
103
+ }
104
+ if (held === ctx.dataBefore?.session) return ctx
105
+ if (ownSeatsOnly(ctx.user)) {
106
+ if (session.selfEnroll !== true) {
107
+ throw invalid({
108
+ message: 'Students of this course do not enroll themselves: the school enrolls them.',
109
+ key: 'error.invalid.enrollment_closed',
110
+ params: { kayan: 'enrollments', field: 'session' },
111
+ })
112
+ }
113
+ if (!yetToStart(session.startsAt)) {
114
+ throw invalid({ message: 'This session has started, so it takes no new seat.', key: 'error.invalid.session_started', params: { kayan: 'enrollments', field: 'session' } })
115
+ }
116
+ }
117
+ if (session.capacity !== null) {
118
+ await holdSeats({ tx: ctx.tx, session: held })
119
+ if ((await seatsTaken({ session: held, tx: ctx.tx })) >= session.capacity) {
120
+ throw conflict({ message: 'Every seat of this session is taken.', key: 'error.conflict.session_full', params: { kayan: 'enrollments', field: 'session' } })
121
+ }
122
+ }
123
+ return ctx
124
+ }
125
+
126
+ /**
127
+ * A student leaves a session only before it starts: the seat it held stays the
128
+ * record that it was there. Whoever reads every seat withdraws one at any time.
129
+ * A seat the actor may not reach loaded no row, and answers the 404 a missing
130
+ * one does. The start is the session's column, so a seat whose course has since
131
+ * left the actor's sight is not said to have started.
132
+ */
133
+ const leavesBeforeStart = async (ctx: Ctx): Promise<Ctx> => {
134
+ if (!ownSeatsOnly(ctx.user) || !ctx.dataBefore) return ctx
135
+ const id = ctx.dataBefore.session
136
+ const session = typeof id === 'string' ? await sessionHeld({ tx: ctx.tx, session: id }) : undefined
137
+ if (!session || !yetToStart(session.startsAt)) {
138
+ throw invalid({ message: 'This session has started, so its seat stays.', key: 'error.invalid.session_started', params: { kayan: 'enrollments' } })
139
+ }
140
+ return ctx
141
+ }
142
+
143
+ // add-on: settings
144
+ /**
145
+ * While the portal is closed for maintenance a student takes and gives back no
146
+ * seat, whatever calls the server; staff keep working. The organization's
147
+ * settings are read in the write's own transaction, since a student reads them
148
+ * as itself and a role might not.
149
+ */
150
+ // The table and column are written out, as `sessionHeld`'s are; R7g fails on a rename.
151
+ const portalOpen = async (ctx: Ctx): Promise<Ctx> => {
152
+ if (!ownSeatsOnly(ctx.user) || !ctx.tenant) return ctx
153
+ const [held] = (await (ctx.tx as NodePgDatabase).execute<{ maintenance: boolean | null }>(sql`select maintenance from settings where organization_id = ${ctx.tenant}`)).rows
154
+ if (held?.maintenance === true) {
155
+ throw invalid({ message: 'The portal is closed for maintenance, so no seat changes from it.', key: 'error.invalid.portal_closed', params: { kayan: 'enrollments' } })
156
+ }
157
+ return ctx
158
+ }
159
+ // end add-on: settings
160
+
161
+ export const enrollments = defineKayan({
162
+ name: 'enrollments',
163
+ access: {
164
+ read: ownSeats,
165
+ create: (args: { user: unknown; data?: unknown }) => !namesAnother(args),
166
+ update: (args: { user: unknown; data?: unknown }) => (namesAnother(args) ? false : ownSeats(args)),
167
+ delete: ownSeats,
168
+ },
169
+ // What a form says where a second seat of one student in one session is
170
+ // refused, over the shared words for a unique value taken.
171
+ messages: {
172
+ conflict: { unique: { en: 'This student already holds a seat in this session.', ar: 'لهذا الطالب مقعد في هذه الحصة بالفعل.' } },
173
+ },
174
+ // A student holds one seat in a session, which is one organization's, so the
175
+ // organization needs no column in the key. The mark is in it with its nulls
176
+ // not distinct, so a withdrawn seat makes room for a new one while two
177
+ // standing seats collide.
178
+ indexes: [{ fields: ['session', 'student', 'deletedAt'], unique: true, nullsNotDistinct: true }],
179
+ fields: ({ defaults }) => ({
180
+ ...defaults,
181
+ organization,
182
+ // Read as the session's course and its start on every screen, where a
183
+ // session alone is titled by its start.
184
+ //
185
+ // Both are required, so no write makes a seat for nobody or in nothing, and
186
+ // a session or a person that holds one is not deleted under it: a required
187
+ // relationship restricts.
188
+ session: {
189
+ kind: 'relationship', label: { en: 'Session', ar: 'الحصة' }, relationTo: () => sessions,
190
+ filterOptions: openSessions,
191
+ rules: { required: true },
192
+ targets: { dashboard: { cell: sessionCell }, portal: { cell: sessionCell }, backoffice: { cell: sessionCell } },
193
+ },
194
+ // A create that names no student enrolls the person making it: the runner's
195
+ // defaults step fills it from the actor, and a field with a default is one a
196
+ // create may leave out, at the HTTP boundary too. A role without the column's
197
+ // key is refused the filled value. A student holds it, so its own enrollment
198
+ // names itself, and `namesAnother` refuses it any other person.
199
+ student: {
200
+ kind: 'relationship', label: { en: 'Student', ar: 'الطالب' }, relationTo: 'user',
201
+ filterOptions: students,
202
+ rules: { required: true, defaultValue: actor.id },
203
+ },
204
+ /**
205
+ * The field the language names as its soft-delete mark, so a student who
206
+ * leaves a session is a row this kayan keeps rather than one it removes: an
207
+ * enrollment is the record that the student was there. It is the one kayan
208
+ * of this template that takes the mark. Nothing points at an enrollment, so
209
+ * no child outlives it, and nothing refers to it through a `restrict`
210
+ * foreign key that a delete would have to release. Nobody supplies the
211
+ * mark: the runner writes it at the delete.
212
+ *
213
+ * `session` and `student` are required, so each restricts, and a session or
214
+ * a person a marked enrollment still names is not deleted under it.
215
+ */
216
+ deletedAt: {
217
+ kind: 'datetime',
218
+ label: { en: 'Deleted at', ar: 'تاريخ الحذف' },
219
+ rules: { providedBy: { create: 'never', update: 'never' } },
220
+ },
221
+ }),
222
+ targets: {
223
+ // Every organization's rows on the backoffice, read-only, and each row's
224
+ // record, which only reads. A record is titled by its student, a person the
225
+ // backoffice reads, where a session is titled only by its start.
226
+ backoffice: {
227
+ useAsTitle: 'student',
228
+ pages: {
229
+ ...backofficeList('/enrollments', { columns: ['session', 'student', 'organization', 'createdAt'], filters: ['organization', 'session', 'student'], sort: '-createdAt' }),
230
+ ...backofficeRecord({
231
+ path: '/enrollments/:id',
232
+ main: [{ section: 'enrollment', label: { en: 'Enrollment', ar: 'التسجيل' }, fields: ['session', 'student', 'deletedAt'] }],
233
+ }),
234
+ },
235
+ },
236
+ service: {
237
+ operations: {
238
+ ...defaultOperations,
239
+ create: { ...defaultOperations.create, steps: ({ steps }) => [
240
+ // add-on: settings
241
+ portalOpen,
242
+ // end add-on: settings
243
+ takesSeat,
244
+ steps.insert,
245
+ ] },
246
+ update: { ...defaultOperations.update, steps: ({ steps }) => [
247
+ // add-on: settings
248
+ portalOpen,
249
+ // end add-on: settings
250
+ takesSeat,
251
+ steps.update,
252
+ ] },
253
+ delete: { ...defaultOperations.delete, steps: ({ steps }) => [
254
+ // add-on: settings
255
+ portalOpen,
256
+ // end add-on: settings
257
+ leavesBeforeStart,
258
+ steps.remove,
259
+ ] },
260
+ },
261
+ },
262
+ // Titled by the student on the dashboard, as on the backoffice, so a tab and a
263
+ // heading tell one seat from another; on the portal, where the student is most
264
+ // often the viewer, by the session.
265
+ dashboard: {
266
+ useAsTitle: 'student',
267
+ description: {
268
+ en: 'Which student holds a seat in which session.',
269
+ ar: 'أي طالب يشغل مقعدا في أي حصة.',
270
+ },
271
+ pages: {
272
+ // The same table every kayan's list is. Searched by the student's name,
273
+ // the one column of a person a tenant reads, and filtered by session,
274
+ // student and when the student enrolled, as the course list is.
275
+ list: { path: '/enrollments', page: appPage([{ layout: 'table', main: [{ list: {
276
+ columns: ['session', 'student', 'createdAt'],
277
+ filters: ['session', 'student', 'createdAt'],
278
+ search: ['student.name'],
279
+ } }] }]) },
280
+ create: { path: '/enrollments/new', page: appPage([{ layout: 'record', main: [{ form: enrollment }] }]) },
281
+ // The editor layout, and the one timestamp worth reading here, named and
282
+ // read-only: when the student enrolled. Not the organization, which on the
283
+ // dashboard is always the active one.
284
+ edit: {
285
+ path: '/enrollments/:id',
286
+ page: appPage([{
287
+ layout: 'record',
288
+ main: [{ form: enrollment }],
289
+ side: [{ section: 'record', label: { en: 'Record', ar: 'السجل' }, fields: [{ field: 'createdAt', readOnly: true }] }, activity],
290
+ danger: danger(),
291
+ }]),
292
+ },
293
+ },
294
+ },
295
+ portal: {
296
+ useAsTitle: 'session',
297
+ pages: {
298
+ // The viewer's seats, upcoming first, drawn whole by the courses feature's portal.
299
+ list: { path: '/enrollments', component: portalUi('enrollments.tsx#Enrollments') },
300
+ // The list's New enrollment, which only staff are offered, with the
301
+ // student as the dashboard draws it, so an instructor picks whom it
302
+ // enrolls. A student who opens it by its address and names another is
303
+ // refused by `namesAnother`; one who names nobody enrolls itself.
304
+ create: { path: '/enrollments/new', page: portalPage([{ form: ['session', 'student'] }]) },
305
+ edit: { path: '/enrollments/:id', page: portalPage([{ form: ['session'] }]) },
306
+ },
307
+ },
308
+ },
309
+ })
@@ -0,0 +1,75 @@
1
+ // What an enrollment's session column draws: the session's course by its title, then when it starts, which tells two sessions of one course apart,
2
+ // then where and how it meets.
3
+ // A session is titled by its start and a title is one field, so the course is a second read.
4
+ //
5
+ // The list's own `useRelationshipTitles` keeps only each session's title, the start as text, and not the course it points at, so this cell reads them again:
6
+ // one read of every session the page's rows point at and one of their courses, shared by every cell of the page through the query client rather than two per cell,
7
+ // and read again once it is as old as the list's own pages. A read that failed is not kept, so the next one tries again.
8
+ // Each read is the viewer's own, so a session or a course the viewer may not read is left out, and its cell keeps what it can say.
9
+ import { use } from 'react'
10
+ import { useQuery } from '@tanstack/react-query'
11
+ import type { CellProps, FetchClient, Row } from 'mithaq/admin'
12
+ import { ListContext, useClient, useLocale, useScreen } from 'mithaq/admin/react'
13
+ import type { FoundPage } from 'mithaq-admin/control-props.ts'
14
+ import { formatDate, noValue } from 'mithaq-admin/format.tsx'
15
+ import { refusalKeyOf } from 'mithaq-admin/refusal.ts'
16
+ import { findByIds, nameOfRow, titleFieldOf } from 'mithaq-admin/relationship-control.tsx'
17
+
18
+ /** A refusal is the viewer's access answering, so it reads as no rows; anything else is a failure, and throws for the query to try again. */
19
+ const unread = (thrown: unknown): never[] => {
20
+ if (refusalKeyOf(thrown)) return []
21
+ throw thrown
22
+ }
23
+
24
+ const of = (client: FetchClient, kayan: string) => ({ find: (input: Record<string, unknown>) => client({ kayan, operation: 'find', input }) as Promise<FoundPage> })
25
+
26
+ /** Some courses by id, as the viewer reads them, which the session picker's label reads too. */
27
+ export const coursesOf = async ({ client, ids, maxLimit }: { client: FetchClient; ids: readonly string[]; maxLimit: number }): Promise<ReadonlyMap<string, Row>> => {
28
+ const courses = ids.length ? await findByIds({ reads: of(client, 'courses'), ids, maxLimit }).catch(unread) : []
29
+ return new Map(courses.map((one) => [String(one.id), one]))
30
+ }
31
+
32
+ const sessionsOf = async ({ client, ids, maxLimit }: { client: FetchClient; ids: readonly string[]; maxLimit: number }): Promise<ReadonlyMap<string, Row>> => {
33
+ const sessions = await findByIds({ reads: of(client, 'sessions'), ids, maxLimit }).catch(unread)
34
+ const courseIds = [...new Set(sessions.flatMap((one) => (typeof one.course === 'string' ? [one.course] : [])))]
35
+ const course = await coursesOf({ client, ids: courseIds, maxLimit })
36
+ return new Map(sessions.map((one) => [String(one.id), { ...one, course: course.get(String(one.course)) }]))
37
+ }
38
+
39
+ export function SessionCell({ field, value, row }: CellProps) {
40
+ const client = useClient()
41
+ const { locale, t } = useLocale()
42
+ const { kayan: { courses, sessions: sessionKayan }, maxLimit } = useScreen()
43
+ // A list's page, or the one row a record page draws.
44
+ // Sorted, so the same rows in another order are the same query.
45
+ const ids = [...new Set((use(ListContext)?.page.docs ?? [row]).flatMap((one: Row) => (typeof one[field.name] === 'string' ? [one[field.name] as string] : [])))].sort()
46
+ const { data: sessions, isError } = useQuery({ queryKey: ['session-cell', ids], queryFn: () => sessionsOf({ client, ids, maxLimit }), staleTime: 30_000 })
47
+ if (typeof value !== 'string') return noValue
48
+ const session = sessions?.get(value)
49
+ const when = formatDate({ value: session?.startsAt, locale })
50
+ const course = session?.course ? nameOfRow({ row: session.course as Row, title: titleFieldOf(courses), locale }) : undefined
51
+ // Where and how it meets, each by its option's label in the viewer's locale, where the viewer reads it.
52
+ const labelOf = (name: string) => {
53
+ const option = sessionKayan?.fields.find((one) => one.name === name)?.options?.find((one) => one.value === session?.[name])
54
+ return option?.label[locale]
55
+ }
56
+ const place = [labelOf('venue'), labelOf('format')].filter(Boolean).join(' · ')
57
+ // Never the id the column holds, as the relationship cell draws none:
58
+ // loading until the read answers, the server's render among it, a dash
59
+ // where it failed, and not available for a session it may not read or one
60
+ // of which it may read neither the course nor the start.
61
+ if (!when && !course) {
62
+ if (sessions) return t('admin.notAvailable', 'Not available')
63
+ return isError ? '—' : t('admin.loading', 'Loading…')
64
+ }
65
+ // Two lines, the course over its start, so a narrow column wraps each
66
+ // whole rather than one into the other.
67
+ return (
68
+ <span className="grid">
69
+ {course ? <span className="font-medium">{course}</span> : null}
70
+ {/* One unbroken run, so a narrow column breaks between the course and the date and never inside the date. */}
71
+ {when ? <span className="text-muted-foreground text-xs"><bdi className="whitespace-nowrap">{when}</bdi></span> : null}
72
+ {place ? <span className="text-muted-foreground text-xs">{place}</span> : null}
73
+ </span>
74
+ )
75
+ }
@@ -0,0 +1,36 @@
1
+ // How a session reads wherever a picker lists or holds one: its course by the
2
+ // course's title, then when it starts, since a session's own title, the start
3
+ // alone, makes two sessions on one day read alike.
4
+ //
5
+ // A session holds its course as an id, and a read populates no relationship, so
6
+ // the courses are a second read: one of every course the picker's rows point at,
7
+ // shared through the query client. Each read is the viewer's, so a course the
8
+ // viewer may not read leaves the session's title, its start, as it was.
9
+ import type { ReactNode } from 'react'
10
+ import { useQuery } from '@tanstack/react-query'
11
+ import type { ProviderProps, Row } from 'mithaq/admin'
12
+ import { useClient, useLocale, useScreen } from 'mithaq/admin/react'
13
+ import { formatDate } from 'mithaq-admin/format.tsx'
14
+ import { nameOfRow, RowLabelContext, titleFieldOf, type UseRowLabel } from 'mithaq-admin/relationship-control.tsx'
15
+ import { coursesOf } from './session-cell.tsx'
16
+
17
+ /** The session's course and its start, or nothing for any other kayan and for a session whose course is not read yet. */
18
+ export const useSessionLabel: UseRowLabel = (target, rows) => {
19
+ const client = useClient()
20
+ const { locale } = useLocale()
21
+ const { kayan, maxLimit } = useScreen()
22
+ const sessions = target === 'sessions'
23
+ // Sorted, so the same courses in another order are the same query.
24
+ const ids = sessions ? [...new Set(rows.flatMap((one) => (typeof one.course === 'string' ? [one.course] : [])))].sort() : []
25
+ const { data: courses } = useQuery({ queryKey: ['session-courses', ids], queryFn: () => coursesOf({ client, ids, maxLimit }), enabled: ids.length > 0, staleTime: 30_000 })
26
+ if (!sessions) return undefined
27
+ return (row: Row) => {
28
+ const course = courses?.get(String(row.course))
29
+ const when = formatDate({ value: row.startsAt, locale })
30
+ // The date's own spaces are no-break ones, so a box too narrow for the whole label breaks between the course
31
+ // and the date and never inside the date.
32
+ return course && when ? `${nameOfRow({ row: course, title: titleFieldOf(kayan.courses), locale })} · ${when.replaceAll(' ', '\u00a0')}` : undefined
33
+ }
34
+ }
35
+
36
+ export const SessionLabels = ({ children }: ProviderProps<ReactNode>) => <RowLabelContext value={useSessionLabel}>{children}</RowLabelContext>
@@ -0,0 +1,8 @@
1
+ import { courses } from './courses/courses.ts'
2
+ import { enrollments } from './enrollments/enrollments.ts'
3
+ import { sessions } from './sessions/sessions.ts'
4
+
5
+ export const kayan = [courses, sessions, enrollments] as const
6
+
7
+ // The images a course's reader reads as its cover, which the asas hands the media feature.
8
+ export { coverReads } from './courses/cover-reads.ts'
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@asas/courses",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts",
8
+ "./domain/*": "./domain/*",
9
+ "./portal/*": "./portal/*"
10
+ },
11
+ "dependencies": {
12
+ "@asas/mithaq": "*",
13
+ "@fontsource-variable/bricolage-grotesque": "^5.3.0",
14
+ "mithaq": "*",
15
+ "mithaq-admin": "*",
16
+ "zod": "^4.6.5"
17
+ },
18
+ "devDependencies": {
19
+ "@types/react": "^19.3.0",
20
+ "lucide-react": "^1.16.0",
21
+ "react": "^19.3.0",
22
+ "@tanstack/react-query": "^5.104.0"
23
+ }
24
+ }