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,601 @@
1
+ import { defaultFields, draftFields, folderOf, invalid, type Ctx, type ReadTools, type WriteTools } from 'mithaq'
2
+ import { z } from 'zod'
3
+ import { defineKayan, organization } from '@asas/mithaq'
4
+ import { activity, activityTab, appPage, danger, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
5
+ import { readsPast } from '@asas/mithaq/roles/tenant-access.ts'
6
+ import { sessions } from '../sessions/sessions.ts'
7
+ import { seatsTaken } from '../sessions/seats.ts'
8
+ import { expectedUpdatedAt } from '@asas/mithaq/steps/expected-updated-at.ts'
9
+ import { archivedRow, marks, notArchived, reason, reasonInput, unarchived } from '../domain/archive.ts'
10
+
11
+ /**
12
+ * Page pieces only this kayan draws with. What both screens share is reached
13
+ * through `ui`, `dashboardUi` and `portalUi` from `../mithaq.ts`.
14
+ */
15
+ const ui = folderOf(new URL('./ui/', import.meta.url))
16
+ /** The portal's pages, which the courses feature draws for its students. */
17
+ const portalUi = folderOf(new URL('../portal/ui/', import.meta.url))
18
+ /** What a fee column draws on every screen. */
19
+ const feeCell = ui('fee-cell.tsx#FeeCell')
20
+ /** What the list's Status column draws. */
21
+ const statusCell = ui('status-cell.tsx#StatusCell')
22
+
23
+ /** When the access code is drawn: while the form holds a live delivery, redrawn as the delivery changes. */
24
+ const live = { delivery: { equals: 'live' } }
25
+ /** The edit page's header pill for an archived course, beside its title and draft status. */
26
+ const archivedBadge = { component: ui('archived-badge.tsx#ArchivedBadge'), when: archivedRow }
27
+ /** Active, Drafts, Archived and All: Active is what a list opens on, so an archived course is out of the way. */
28
+ const stateTabs = [
29
+ { tab: 'active', label: { en: 'Active', ar: 'النشطة' }, where: notArchived },
30
+ { tab: 'drafts', label: { en: 'Drafts', ar: 'المسودات' }, where: { and: [notArchived, { _status: { equals: 'draft' } }] } },
31
+ { tab: 'archived', label: { en: 'Archived', ar: 'المؤرشفة' }, where: { archived: { equals: true } } },
32
+ { tab: 'all', label: { en: 'All', ar: 'الكل' } },
33
+ ] as const
34
+
35
+ /** Whether the actor runs the catalogue and so reads every course and every session. */
36
+ export const runsCatalogue = (user: unknown): boolean => readsPast(user, 'courses.archive') || readsPast(user, 'screen.dashboard')
37
+
38
+ /**
39
+ * Archived courses are closed to students and instructors and open to whoever
40
+ * runs the catalogue. The dashboard's screen key counts because the registrar
41
+ * reads archived courses there without holding `courses.archive`. Every read
42
+ * answers from this, including relationships; the portal's `baseFilter` only
43
+ * narrows its list. Both arms, since a row from before the column existed holds `null`.
44
+ */
45
+ const archivedToArchivers = ({ user }: { user: unknown }) =>
46
+ runsCatalogue(user) ? true : { or: [{ archived: { exists: false } }, { archived: { equals: false } }] }
47
+
48
+ /**
49
+ * What a course's create and edit pages both draw, written once so the two
50
+ * cannot drift. The access code follows the delivery the form holds.
51
+ */
52
+ const details = [
53
+ { section: 'about', label: { en: 'About the course', ar: 'عن الدورة' }, fields: ['title', 'summary', 'cover', 'details'] },
54
+ { section: 'pricing', label: { en: 'Pricing', ar: 'التسعير' }, fields: [{ row: ['fee', 'currency'] }] },
55
+ { section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', { field: 'accessCode', when: live }, 'notifications', { field: 'selfEnroll', component: 'switch' }] },
56
+ ] as const
57
+ const catalogue = { section: 'catalogue', label: { en: 'Catalogue', ar: 'الدليل' }, fields: ['tags'] } as const
58
+ /**
59
+ * The edit page's Catalogue, with `archived` read only: a course is archived
60
+ * through `archive` alone, which records its reason. A create leaves it out.
61
+ */
62
+ const archivedCatalogue = { ...catalogue, fields: [...catalogue.fields, { field: 'archived', readOnly: true }] } as const
63
+
64
+ /** What a course's create operation accepts for each session it makes with it. */
65
+ const sessionInput = z.object({ startsAt: z.date(), capacity: z.number().nullish() })
66
+
67
+ /**
68
+ * The step after `insert`. Each session is one managed write through
69
+ * `tools.of(sessions)`, which carries the actor, the depth counter and the
70
+ * transaction, so a session that throws rolls the course back.
71
+ *
72
+ * `sessions` imports this module for its `course` thunk, so the parameters and
73
+ * the return are annotated: inferring them from that call would make the two
74
+ * kayans' types circular.
75
+ */
76
+ const createSessions = async (
77
+ ctx: Ctx<{ id?: string; _status?: string | null; sessions?: readonly z.infer<typeof sessionInput>[] }>,
78
+ tools: WriteTools,
79
+ ): Promise<Ctx> => {
80
+ const { id, _status, sessions: made } = ctx.data
81
+ if (!made?.length) return ctx
82
+ // A draft is read by no plain read, so a session pointing at it would name a
83
+ // course that read cannot show. The runner has written `_status` by now; the
84
+ // throw rolls the course back.
85
+ if (_status === 'draft') {
86
+ throw invalid({
87
+ message: 'A course saved as a draft makes no sessions. Publish the course, then add its sessions.',
88
+ key: 'error.invalid.draft_sessions',
89
+ params: { kayan: 'courses' },
90
+ })
91
+ }
92
+ // The insert step has run, so `id` is set; the create shape types it optional
93
+ // because a caller may leave it to the column's default.
94
+ if (!id) throw new Error('The course was inserted with no id, so its sessions have nothing to point at.')
95
+ for (const session of made) {
96
+ await tools.of(sessions).create({ ...session, course: id })
97
+ }
98
+ return ctx
99
+ }
100
+
101
+ /**
102
+ * What `findWithSessions` puts on the course, one entry per session it may
103
+ * read, with the seats it has left: `null` where it holds any number. A reader
104
+ * who sees only its own seats, a student, could not count the others'.
105
+ */
106
+ const sessionRow = z.object({
107
+ id: z.guid(),
108
+ startsAt: z.date(),
109
+ capacity: z.number().nullable(),
110
+ venue: z.string().nullish(),
111
+ format: z.string().nullish(),
112
+ archived: z.boolean().nullish(),
113
+ seatsLeft: z.number().nullable(),
114
+ })
115
+
116
+ /**
117
+ * The step after `findOne`. The course is loaded by then, so its sessions are
118
+ * one managed read through `tools.of(sessions)`, which runs the sessions kayan's own
119
+ * access, so a session this actor may not read is not in what comes back. A
120
+ * limit of zero is no page limit.
121
+ */
122
+ const attachSessions = async (ctx: Ctx, tools: ReadTools): Promise<Ctx> => {
123
+ // `ctx.data` is typed as what the operation parses, but this step runs after
124
+ // `findOne`, which leaves the row or `null`: narrow it rather than cast.
125
+ const course: unknown = ctx.data
126
+ if (!course || typeof course !== 'object' || !('id' in course) || typeof course.id !== 'string') return ctx
127
+ const found = await tools.of(sessions).find({
128
+ where: { course: { equals: course.id } },
129
+ limit: 0,
130
+ })
131
+ // Each session was read under the reader's access, so only the seats of a session it may read are counted.
132
+ // ponytail: one count per session, a grouped count when a course holds many sessions.
133
+ const counted = await Promise.all(found.docs.map(async (one) => ({
134
+ ...one,
135
+ seatsLeft: typeof one.capacity === 'number' ? Math.max(0, one.capacity - (await seatsTaken({ session: one.id }))) : null,
136
+ })))
137
+ return { ...ctx, data: { ...course, sessions: counted } }
138
+ }
139
+
140
+ export const courses = defineKayan({
141
+ name: 'courses',
142
+ // ANDed with the inherited rows, so it narrows and never widens.
143
+ access: { read: archivedToArchivers },
144
+ /**
145
+ * The singular and plural names are reserved keys of the kayan's own copy
146
+ * scope. The nav and every screen's heading read them, and a kayan that
147
+ * declares neither is shown under its own name.
148
+ */
149
+ messages: {
150
+ singular: { en: 'Course', ar: 'دورة' },
151
+ plural: { en: 'Courses', ar: 'الدورات' },
152
+ // Nested under the field's own name so that name is written once; `buildAsas`
153
+ // joins the parts into `fee.invalid` and `fee.min`. `fee` carries its own zod
154
+ // shape, so mithaq seeds neither key: declaring them here keeps
155
+ // `mithaq generate messages` from pruning them.
156
+ fee: {
157
+ invalid: { en: 'A fee is a number.', ar: 'الرسوم رقم.' },
158
+ min: { en: 'A fee is never negative.', ar: 'الرسوم لا تكون سالبة أبدًا.' },
159
+ },
160
+ },
161
+ targets: {
162
+ /** Every organization's rows on the backoffice, read-only, with each row's record page and its sessions. */
163
+ backoffice: {
164
+ useAsTitle: 'title',
165
+ pages: {
166
+ ...backofficeList('/courses', { columns: ['title', 'organization', 'fee', 'archived', 'createdAt'], filters: ['organization', 'title', 'fee', 'archived'], search: ['title'], sort: '-createdAt' }),
167
+ ...backofficeRecord({
168
+ path: '/courses/:id',
169
+ main: [{ tabs: [
170
+ { tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [
171
+ { section: 'about', label: { en: 'About the course', ar: 'عن الدورة' }, fields: ['title', 'summary', 'details'] },
172
+ { section: 'pricing', label: { en: 'Pricing', ar: 'التسعير' }, fields: [{ row: ['fee', 'currency'] }, 'audience', 'topics'] },
173
+ { section: 'teaching', label: { en: 'Teaching', ar: 'التدريس' }, fields: ['delivery', 'accessCode', 'level', 'opensOn', 'selfEnroll'] },
174
+ { section: 'notes', label: { en: 'Notes', ar: 'ملاحظات' }, fields: ['archiveReason'] },
175
+ ] },
176
+ { tab: 'sessions', label: { en: 'Sessions', ar: 'الجلسات' }, fields: [{ list: {
177
+ of: 'sessions',
178
+ where: 'course',
179
+ columns: ['startsAt', 'capacity', 'venue', 'format', 'archived'],
180
+ filters: ['venue', 'archived'],
181
+ search: ['venue'],
182
+ sort: 'startsAt',
183
+ } }] },
184
+ ] }],
185
+ side: [{ section: 'catalogue', label: { en: 'Catalogue', ar: 'الدليل' }, fields: ['tags', 'archived', 'notifications'] }],
186
+ }),
187
+ },
188
+ },
189
+ /**
190
+ * A course keeps a copy of itself on every write. A change may be saved as a
191
+ * draft, autosaved in place, before it is published; a plain read leaves a
192
+ * course never published out.
193
+ */
194
+ versions: { drafts: { autosave: true } },
195
+ /**
196
+ * The operations this kayan adds or restates. The store and schema they run
197
+ * through are the service target's own.
198
+ */
199
+ service: {
200
+ // The defaults hold versions' three operations too, since a course keeps its versions.
201
+ operations: ({ defaults }) => ({
202
+ ...defaults,
203
+ /**
204
+ * The shipped create list with one step added and one shape widened:
205
+ * `sessions` is part of the create signature without being a column, since
206
+ * the store step writes the kayan's own fields alone.
207
+ */
208
+ create: {
209
+ input: ({ create }) => create.extend({ sessions: z.array(sessionInput).optional() }),
210
+ steps: ({ steps }) => [steps.insert, createSessions],
211
+ },
212
+ /**
213
+ * The shipped update and delete lists with the `expectedUpdatedAt` step
214
+ * before each, and each input widened by the key the step reads. A draft
215
+ * reader holds the instant of the row's latest version, so a draft save,
216
+ * an autosave, a publish or a Discard is refused 409 when another writer
217
+ * saved since. The edit form sends the key on each; it stays optional, so
218
+ * a caller that sends none, a bulk among them, writes as before.
219
+ */
220
+ update: {
221
+ input: ({ update }) => update.extend({ expectedUpdatedAt: z.date().optional() }),
222
+ steps: ({ steps }) => [expectedUpdatedAt, steps.update],
223
+ },
224
+ delete: {
225
+ input: ({ id }) => id.extend({ expectedUpdatedAt: z.date().optional() }),
226
+ steps: ({ steps }) => [expectedUpdatedAt, steps.remove],
227
+ },
228
+ /**
229
+ * A restore takes the key too, so Discard changes and a restore from the
230
+ * Versions tab are refused 409 over a newer draft another person saved,
231
+ * which a restore would otherwise bury.
232
+ */
233
+ restoreVersion: {
234
+ input: ({ id }) => id.extend({ expectedUpdatedAt: z.date().optional() }),
235
+ steps: ({ steps }) => [expectedUpdatedAt, steps.restoreVersion],
236
+ },
237
+ /**
238
+ * A custom operation is one more list and gets what `create` gets: the
239
+ * transaction, the parse and the defaults. Its name is the action an access
240
+ * row grants. Naming `input` orders the two the runner owns: the caller's
241
+ * shape is parsed first and the defaults fill after it, so `updatedAt` moves
242
+ * as in the shipped `update` without this strict shape naming a key the
243
+ * caller never sends.
244
+ */
245
+ archive: {
246
+ // What a screen's button asks for beyond the row's id, said as fields: the
247
+ // dialog draws `reason` through the control every text field uses and keys
248
+ // its label `courses.operations.archive.reason`. `buildAsas` refuses a
249
+ // field it names no key for. A screen that declares none has no dialog.
250
+ targets: { dashboard: { fields: { reason } } },
251
+ input: () => reasonInput('courses.operations.archive'),
252
+ steps: ({ steps }) => [marks(true), steps.update],
253
+ },
254
+ /** Brings an archived course back into the catalogue, with the reason, which the activity keeps. */
255
+ unarchive: {
256
+ targets: { dashboard: { fields: { reason } } },
257
+ input: () => reasonInput('courses.operations.unarchive'),
258
+ steps: ({ steps }) => [marks(false), steps.update],
259
+ },
260
+ /**
261
+ * Payload's Unpublish: the course goes back to a draft, kept as it is and
262
+ * read by draft readers alone until it is published again. The caller
263
+ * sends the status it asks for, since an operation of its own keeps the
264
+ * row's status unless the call names one.
265
+ */
266
+ unpublish: {
267
+ input: ({ id }) => id.extend({ _status: z.literal('draft') }),
268
+ steps: ({ steps }) => [steps.update],
269
+ },
270
+ /**
271
+ * A read list with one step of its own and an `output` written the way
272
+ * `input` is, run once at `buildAsas`. It types `sessions` on the result,
273
+ * which no field declares and the row boundary does not know.
274
+ */
275
+ findWithSessions: {
276
+ // The id and nothing else, picked off the derived row shape. The row is
277
+ // stripped rather than strict, because it comes from the store; `.strict()`
278
+ // puts that refusal back.
279
+ input: ({ row }) => row.pick({ id: true }).strict(),
280
+ steps: ({ steps }) => [steps.findOne, attachSessions],
281
+ // Every column optional but the id: the output is read after the reader's projection, which leaves out a
282
+ // column the reader holds no key for, as a student's leaves out the access code.
283
+ output: ({ row }) => row.partial().required({ id: true }).extend({ sessions: z.array(sessionRow) }),
284
+ },
285
+ }),
286
+ },
287
+ dashboard: {
288
+ // What this kayan is for, drawn under the list heading and the form title.
289
+ // One string per declared locale, keyed `courses.page.description`.
290
+ description: {
291
+ en: 'Everything on offer in the catalogue, and what a seat on each costs.',
292
+ ar: 'كل ما هو معروض في الدليل، وما يكلفه مقعد في كل منها.',
293
+ },
294
+ useAsTitle: 'title',
295
+ // Each page is one tree: the `app` shell at its root with the title in its
296
+ // header and the nav in its nav region, a layout in its main region, and what
297
+ // fills each region. Between them they draw a row, sections, tabs, a related
298
+ // list, a component, a danger-zone button that asks before it runs, and a
299
+ // read-only leaf. The board is rooted at the second shell, `top`, and names
300
+ // its layout and component by `ui()`.
301
+ pages: {
302
+ list: {
303
+ path: '/courses',
304
+ page: appPage([{ layout: 'table', main: [{ list: {
305
+ // `_status` says whether each row is a draft or published, since this
306
+ // list reads every row's latest draft.
307
+ columns: ['title', 'fee', 'opensOn', '_status', 'details', 'cover'],
308
+ filters: ['title', 'fee', 'archived'],
309
+ // `details` is searched because this list names it: a rich text field
310
+ // offers itself to no search box on its own.
311
+ search: ['title', 'summary', 'details'],
312
+ empty: ui('no-courses.tsx#NoCourses'),
313
+ // Over the ticked rows: a delete behind its question, and one dialog that
314
+ // writes the fields it names over every one of them.
315
+ bulk: [
316
+ {
317
+ button: 'deleteMany',
318
+ label: { en: 'Delete', ar: 'حذف' },
319
+ confirm: {
320
+ title: { en: 'Delete the ticked courses?', ar: 'حذف الدورات المحددة؟' },
321
+ body: { en: 'This cannot be undone.', ar: 'لا يمكن التراجع عن هذا.' },
322
+ },
323
+ },
324
+ // Archived is not among them: a course leaves and comes back through its own operations.
325
+ { button: 'updateMany', label: { en: 'Update', ar: 'تحديث' }, fields: ['fee', 'notifications'] },
326
+ ],
327
+ // On each row, the edit page's own archive or unarchive, asking for its reason.
328
+ row: [
329
+ { button: 'archive', when: unarchived, label: { en: 'Archive', ar: 'أرشفة' }, fields: ['reason'] },
330
+ { button: 'unarchive', when: archivedRow, label: { en: 'Unarchive', ar: 'إلغاء الأرشفة' }, fields: ['reason'] },
331
+ ],
332
+ tabs: stateTabs,
333
+ } }] }]),
334
+ },
335
+ // The edit page's Details and Catalogue, and nothing that needs a row yet:
336
+ // no Sessions or Versions tab, no activity, no danger zone.
337
+ create: {
338
+ path: '/courses/new',
339
+ page: appPage([{ layout: 'record', main: [{ form: details }], side: [catalogue] }]),
340
+ },
341
+ // The editor's tabs sit across the page, outside the cards. On Details a
342
+ // main column of sectioned fields has a side column beside it, and the danger
343
+ // zone is at its foot. Sessions and Versions take the page's full width, and
344
+ // the form's controls sit in the header on every tab. The page names every
345
+ // field it draws, and no id, tenant or timestamp.
346
+ edit: {
347
+ path: '/courses/:id',
348
+ page: { ...appPage([{ tabs: [
349
+ { tab: 'details', label: { en: 'Details', ar: 'التفاصيل' }, fields: [{
350
+ layout: 'record',
351
+ main: [{ form: details }],
352
+ // Beside the form, what was done to this row, by whom and when.
353
+ side: [archivedCatalogue, activity],
354
+ // Where a button sits is where it is drawn: in the record layout's danger
355
+ // zone, Unpublish over a published course, Archive over one not archived and
356
+ // Unarchive over one that is, each asking before it runs, before the delete
357
+ // every edit page ends its danger zone with.
358
+ danger: danger({
359
+ button: 'unpublish',
360
+ when: { 'record._status': { equals: 'published' } },
361
+ label: { en: 'Unpublish', ar: 'إلغاء النشر' },
362
+ component: ui('unpublish-button.tsx#UnpublishButton'),
363
+ reload: true,
364
+ confirm: {
365
+ title: { en: 'Unpublish this course?', ar: 'إلغاء نشر هذه الدورة؟' },
366
+ body: { en: 'It goes back to a draft that no student sees, until it is published again.', ar: 'تعود مسودة لا يراها أي طالب حتى تُنشر مجددا.' },
367
+ },
368
+ }, {
369
+ button: 'archive',
370
+ when: unarchived,
371
+ label: { en: 'Archive', ar: 'أرشفة' },
372
+ fields: ['reason'],
373
+ reload: true,
374
+ confirm: {
375
+ title: { en: 'Archive this course?', ar: 'أرشفة هذه الدورة؟' },
376
+ body: { en: 'It leaves the catalogue for every student.', ar: 'تخرج من الدليل لكل الطلاب.' },
377
+ },
378
+ }, {
379
+ button: 'unarchive',
380
+ when: archivedRow,
381
+ label: { en: 'Unarchive', ar: 'إلغاء الأرشفة' },
382
+ fields: ['reason'],
383
+ reload: true,
384
+ confirm: {
385
+ title: { en: 'Unarchive this course?', ar: 'إلغاء أرشفة هذه الدورة؟' },
386
+ body: { en: 'It is back in the catalogue as it was.', ar: 'تعود إلى الدليل كما كانت.' },
387
+ },
388
+ }),
389
+ }] },
390
+ // The sessions are rows of another kayan pointing at this one: a list of
391
+ // `sessions` whose `course` is this row, searched, filtered and paged on
392
+ // the server, each row edited in place below the table or on its own page,
393
+ // and every write the sessions kayan's own, apart from this form's save.
394
+ { tab: 'sessions', label: { en: 'Sessions', ar: 'الجلسات' }, fields: [{ list: {
395
+ of: 'sessions',
396
+ where: 'course',
397
+ columns: ['startsAt', 'capacity', 'venue', 'format', 'archived'],
398
+ filters: ['venue', 'archived'],
399
+ search: ['venue'],
400
+ sort: 'startsAt',
401
+ bulk: [{
402
+ button: 'archiveMany',
403
+ label: { en: 'Archive', ar: 'أرشفة' },
404
+ confirm: {
405
+ title: { en: 'Archive the ticked sessions?', ar: 'أرشفة الجلسات المحددة؟' },
406
+ body: { en: 'They stay on the course, marked archived.', ar: 'تبقى في الدورة، معلمة بأنها مؤرشفة.' },
407
+ },
408
+ }],
409
+ } }] },
410
+ // The row's versions, newest first, each restored behind its confirm,
411
+ // drawn only for an actor holding the key that reads them; a tab whose
412
+ // every node is hidden is not drawn at all.
413
+ { tab: 'versions', label: { en: 'Versions', ar: 'الإصدارات' }, fields: [
414
+ { component: 'versions', when: { '$user.collections.courses.operations.findVersions': { equals: true } } },
415
+ ] },
416
+ // Beside them, the row's whole activity, searched and filtered; the side column keeps the last few.
417
+ activityTab,
418
+ ] }]), header: [{ title: {} }, archivedBadge, { controls: {} }] },
419
+ },
420
+ board: {
421
+ path: '/courses/board',
422
+ page: { shell: 'top', header: [{ title: {} }], nav: [{ nav: {} }], main: [{ layout: 'table', main: [{ component: ui('no-courses.tsx#NoCourses') }] }] },
423
+ },
424
+ },
425
+ },
426
+ /**
427
+ * The students' own pages, each drawn whole by the courses feature's portal: the catalogue of what is on
428
+ * offer, and a course's page with its sessions and, where the course lets students enroll themselves, the
429
+ * seats they take. Both read the courses that are published and not archived alone.
430
+ */
431
+ portal: {
432
+ useAsTitle: 'title',
433
+ description: { en: 'The courses on offer.', ar: 'الدورات المعروضة.' },
434
+ pages: {
435
+ list: { path: '/courses', component: portalUi('catalogue.tsx#Catalogue') },
436
+ view: { path: '/courses/:id', component: portalUi('course.tsx#Course') },
437
+ },
438
+ },
439
+ },
440
+ fields: {
441
+ ...defaultFields,
442
+ organization,
443
+ title: {
444
+ kind: 'text',
445
+ label: { en: 'Title', ar: 'العنوان' },
446
+ // What the field is and the hint its empty control shows, written once
447
+ // at the field's root and drawn the same on every screen. Both are copy,
448
+ // keyed `courses.title.description` and `courses.title.placeholder`.
449
+ description: { en: 'The name a student sees in the catalogue.', ar: 'الاسم الذي يراه الطالب في الدليل.' },
450
+ placeholder: { en: 'Introduction to Arabic calligraphy', ar: 'مقدمة في الخط العربي' },
451
+ // What the input group draws before the value, one string per locale,
452
+ // on every screen that draws the field.
453
+ prefix: { en: 'Course', ar: 'دورة' },
454
+ targets: {
455
+ dashboard: {
456
+ filter: true,
457
+ // What the list's search box runs a `like` over. One opt-in per
458
+ // field, so a name no field of this kayan carries cannot be written.
459
+ searchable: true,
460
+ },
461
+ },
462
+ // Trimmed before it is judged, so a title of spaces alone is refused as
463
+ // required on publish rather than saved as a course with no name. A draft
464
+ // save parses the kinds alone and keeps it.
465
+ rules: { required: true, trim: true, maxLength: 120, index: true },
466
+ },
467
+ // Optional, and carrying a length rule: the rule has to run on the string
468
+ // shape, which is only true while the shape is still a string.
469
+ summary: {
470
+ kind: 'text',
471
+ label: { en: 'Summary', ar: 'الملخص' },
472
+ rules: { maxLength: 200 },
473
+ // Searched beside the title, and never ordered by: a summary is a
474
+ // paragraph, so a page sorted by it reads as an arbitrary order, and a
475
+ // caller naming it is refused the way a caller naming a localized field is.
476
+ targets: { dashboard: { searchable: true, sortable: false } },
477
+ },
478
+ // A fee is never negative. `rules: { min: 0 }` would say so with the same
479
+ // two keys; the shape is this field's own on purpose, as the template's one
480
+ // field that carries its whole zod shape. It is `nullish` because the form
481
+ // sends `null` for a fee it cleared. The column stays derived.
482
+ //
483
+ // A shape written here is one mithaq wrote none of, so it names its own copy
484
+ // keys or an Arabic reader gets zod's English: `courses.fee.invalid` for a
485
+ // value that is not a number and `courses.fee.min` for a negative one.
486
+ // Neither is seeded, because the seed loop passes over a field whose schema
487
+ // target carries its own shape; this kayan's `messages` entry above claims both.
488
+ fee: {
489
+ kind: 'number',
490
+ label: { en: 'Fee', ar: 'الرسوم' },
491
+ // And after it: what one fee buys.
492
+ suffix: { en: 'per seat', ar: 'لكل مقعد' },
493
+ // A column reads the fee in the row's own currency on every screen
494
+ // A column reads the fee in the row's own currency on every screen.
495
+ targets: {
496
+ zod: z.number({ error: 'courses.fee.invalid' }).nonnegative({ error: 'courses.fee.min' }).nullish(),
497
+ dashboard: { filter: true, cell: feeCell },
498
+ portal: { cell: feeCell },
499
+ backoffice: { cell: feeCell },
500
+ },
501
+ },
502
+ currency: {
503
+ kind: 'text',
504
+ label: { en: 'Currency', ar: 'العملة' },
505
+ options: [
506
+ { value: 'SAR', label: { en: 'Saudi riyal', ar: 'ريال سعودي' } },
507
+ { value: 'USD', label: { en: 'US dollar', ar: 'دولار أمريكي' } },
508
+ ],
509
+ // One field, drawn as a `select` on the dashboard and as `radio` on the
510
+ // portal: what it holds is written once, and each screen says only how
511
+ // it draws it.
512
+ targets: { dashboard: { component: 'select' }, portal: { component: 'radio' } },
513
+ },
514
+ level: {
515
+ kind: 'number', label: { en: 'Level', ar: 'المستوى' }, rules: { min: 1, max: 5 },
516
+ targets: { dashboard: { component: 'slider' } },
517
+ },
518
+ notifications: {
519
+ kind: 'checkbox', label: { en: 'Notifications', ar: 'الإشعارات' },
520
+ targets: { dashboard: { component: 'switch', cell: 'checkbox' } },
521
+ },
522
+ delivery: {
523
+ kind: 'text', label: { en: 'Delivery', ar: 'طريقة التقديم' },
524
+ options: [
525
+ { value: 'live', label: { en: 'Live', ar: 'مباشر' } },
526
+ { value: 'recorded', label: { en: 'Recorded', ar: 'مسجل' } },
527
+ ],
528
+ // A choice on both screens, so each reads the option's label in the
529
+ // viewer's locale and never the stored value.
530
+ targets: { dashboard: { component: 'radio' }, portal: { component: 'radio' } },
531
+ },
532
+ audience: {
533
+ kind: 'text', label: { en: 'Audience', ar: 'الجمهور' },
534
+ options: [
535
+ { value: 'beginner', label: { en: 'Beginner', ar: 'مبتدئ' } },
536
+ { value: 'advanced', label: { en: 'Advanced', ar: 'متقدم' } },
537
+ ],
538
+ targets: { dashboard: { component: 'toggleGroup' } },
539
+ },
540
+ accessCode: {
541
+ kind: 'text', label: { en: 'Access code', ar: 'رمز الدخول' }, rules: { maxLength: 6 },
542
+ targets: { dashboard: { component: 'otp' } },
543
+ },
544
+ // Written by `archive` and `unarchive` alone, which record why: no create or
545
+ // plain update names it, so no key past theirs archives a course.
546
+ archived: { kind: 'checkbox', label: { en: 'Archived', ar: 'مؤرشف' }, rules: { providedBy: { create: 'never', update: 'application' } }, targets: { dashboard: { filter: true } } },
547
+ // What a course covers, several at once out of a closed list. A course names
548
+ // at most two, so a third box ticked is refused before the save. No floor: an
549
+ // optional list left empty passes any `minRows`, and a list that is there
550
+ // holds one already.
551
+ topics: {
552
+ kind: 'textArray',
553
+ label: { en: 'Topics', ar: 'المواضيع' },
554
+ rules: { maxRows: 2 },
555
+ options: [
556
+ { value: 'script', label: { en: 'Script', ar: 'الخط' } },
557
+ { value: 'grammar', label: { en: 'Grammar', ar: 'النحو' } },
558
+ { value: 'recitation', label: { en: 'Recitation', ar: 'التلاوة' } },
559
+ ],
560
+ },
561
+ // The day enrolment opens, a calendar day with no time of its own, which
562
+ // the portal draws as the backoffice's own day picker.
563
+ opensOn: { kind: 'date', label: { en: 'Opens on', ar: 'يفتح في' } },
564
+ archiveReason: {
565
+ kind: 'text',
566
+ label: { en: 'Archive reason', ar: 'سبب الأرشفة' },
567
+ rules: { maxLength: 200, providedBy: { create: 'never', update: 'application' } },
568
+ },
569
+ // The many-to-many: no column on this table at all, and a join table
570
+ // `courses_tags` the store writes in the same transaction the course is
571
+ // written in. The whole array replaces the old set on an update, and the join
572
+ // rows go with the course when it is deleted.
573
+ tags: {
574
+ kind: 'relationship',
575
+ relationTo: 'tags',
576
+ hasMany: true,
577
+ label: { en: 'Tags', ar: 'الوسوم' },
578
+ },
579
+ // The same rows a read with a depth populates. A page draws them as the list
580
+ // of `sessions` on the edit page's Sessions tab, and never as a leaf.
581
+ scheduledSessions: {
582
+ kind: 'join', relationTo: 'sessions', on: 'course',
583
+ label: { en: 'Scheduled sessions', ar: 'الجلسات المجدولة' },
584
+ },
585
+ // Whether this course is a draft or published; a kayan that keeps drafts spreads these last.
586
+ ...draftFields,
587
+ _status: { ...draftFields._status, targets: { dashboard: { cell: statusCell } } },
588
+ // What the course covers at length, as Lexical's editor state, drawn by the
589
+ // registry's editor and read by its viewer, and as its plain text in a list.
590
+ // Declared last so a pushed table and a migrated one hold the column in the same place.
591
+ details: { kind: 'richText', label: { en: 'Details', ar: 'التفاصيل' } },
592
+ // The course's cover, one of the organization's images. Its row cannot be
593
+ // deleted while a course points at it, since an upload restricts. Declared
594
+ // last, for the same column order.
595
+ cover: { kind: 'upload', relationTo: 'media', label: { en: 'Cover image', ar: 'صورة الغلاف' } },
596
+ // Whether a student takes a seat in this course's sessions from the portal: only where it is true, so a course
597
+ // that never says is one whose staff enroll each student. No default, which every create would then write, and
598
+ // a role without the column's key would be refused. Declared last, for the same column order.
599
+ selfEnroll: { kind: 'checkbox', label: { en: 'Students may enroll themselves', ar: 'يسجّل الطلاب أنفسهم' } },
600
+ },
601
+ })
@@ -0,0 +1,33 @@
1
+ import { actorPermissions, type Where } from 'mithaq'
2
+ import { serverTargets } from '@asas/mithaq'
3
+ import { currentOrganization } from '@asas/mithaq/roles/tenant-access.ts'
4
+ import { courses } from './courses.ts'
5
+
6
+ /**
7
+ * The images an actor reads as covers: the cover of a published course it
8
+ * reads, of the organization its session has active, under the courses kayan's
9
+ * own read access for this actor. So a student or an instructor reads no image
10
+ * of a draft, of an archived course or of none. The media feature asks it of
11
+ * an actor that holds no `media.create`, and holds no import of this file.
12
+ *
13
+ * The organization is read off the trusted snapshot and named here, since the
14
+ * courses are read straight through the store, past the runner's scope. An
15
+ * image has no field that points at a course, so a row filter cannot reach
16
+ * one, and the covers are read first and named by id.
17
+ */
18
+ export const coverReads = async ({ user }: { user: unknown }): Promise<Where | false> => {
19
+ const held = actorPermissions(user)
20
+ if (held !== true && !held?.has('courses.find')) return false
21
+ const active = currentOrganization(user)
22
+ const readable = active && (await courses.access!.read!({ user }))
23
+ if (!readable) return false
24
+ const published: Where = { and: [{ organization: { equals: active } }, { _status: { equals: 'published' } }] }
25
+ const found = await serverTargets.drizzle.find({ kayan: 'courses', args: {
26
+ limit: 0,
27
+ page: 1,
28
+ sort: { field: 'id', direction: 'asc' },
29
+ where: readable === true ? published : { and: [readable, published] },
30
+ } })
31
+ const ids = [...new Set(found.docs.map((row) => row.cover).filter((id): id is string => typeof id === 'string'))]
32
+ return ids.length ? { id: { in: ids } } : false
33
+ }
@@ -0,0 +1,9 @@
1
+ // The edit page's header pill for an archived course, beside the title and the
2
+ // draft status, so a course out of the catalogue says so on every tab.
3
+ import { useLocale } from 'mithaq/admin/react'
4
+ import { Badge } from '@/components/ui/badge'
5
+
6
+ export function ArchivedBadge() {
7
+ const { t } = useLocale()
8
+ return <Badge data-status="archived" className="bg-destructive/10 text-destructive">{t('version.archived', 'Archived')}</Badge>
9
+ }