create-okjt-app 0.1.0 → 0.1.1
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.
- package/README.md +48 -48
- package/bin/create-okjt-app.mjs +88 -88
- package/package.json +29 -29
- package/src/answers.mjs +93 -93
- package/src/create.mjs +132 -132
- package/src/palette.mjs +42 -42
- package/src/templates/backend.mjs +828 -837
- package/src/templates/frontend.mjs +877 -877
- package/src/templates/root.mjs +145 -137
|
@@ -1,837 +1,828 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Laravel API: files written over a fresh `laravel/laravel` install, as
|
|
3
|
-
* path → contents. `p` is the project (answers.mjs › describeProject).
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
const lines = (...parts) => parts.filter((part) => part !== false && part != null).join('\n');
|
|
7
|
-
const q = (text) => `'${String(text).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
8
|
-
|
|
9
|
-
export function backendFiles(p) {
|
|
10
|
-
const files = {
|
|
11
|
-
'config/okjt.php': okjtConfig(p),
|
|
12
|
-
'bootstrap/app.php': bootstrap(),
|
|
13
|
-
'routes/api.php': apiRoutes(p),
|
|
14
|
-
'routes/web.php': webRoutes(p),
|
|
15
|
-
'app/Models/User.php': userModel(p),
|
|
16
|
-
[`app/Models/${p.workspace.Model}.php`]: workspaceModel(p),
|
|
17
|
-
'app/Models/Note.php': noteModel(),
|
|
18
|
-
'app/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
'
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
'database/migrations/
|
|
25
|
-
'database/
|
|
26
|
-
'
|
|
27
|
-
'tests/Feature/StarterTest.php': starterTest(p),
|
|
28
|
-
};
|
|
29
|
-
if (p.accounts) files['app/Models/Account.php'] = accountModel(p);
|
|
30
|
-
return files;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Project settings over the kit's (its file is the full, commented list). */
|
|
34
|
-
function okjtConfig(p) {
|
|
35
|
-
const w = p.workspace;
|
|
36
|
-
const open = [
|
|
37
|
-
'api/login', 'api/logout', 'api/register', 'api/user', 'api/verify-2fa', 'api/email/*', 'api/2fa/*',
|
|
38
|
-
'api/collaborators/accept', `api/${w.path}`,
|
|
39
|
-
p.features.google && 'api/auth/google',
|
|
40
|
-
p.accounts && 'api/team', p.accounts && 'api/team/*',
|
|
41
|
-
p.features.billing && 'api/plans', p.features.billing && 'api/billing', p.features.billing && 'api/billing/*', p.features.billing && 'api/paystack/webhook',
|
|
42
|
-
p.features.console && 'api/console/*',
|
|
43
|
-
p.features.backups && 'api/backups', p.features.backups && 'api/backups/*',
|
|
44
|
-
].filter(Boolean);
|
|
45
|
-
const roles = p.roles.map((r) => r.value);
|
|
46
|
-
|
|
47
|
-
return lines(
|
|
48
|
-
'<?php',
|
|
49
|
-
'',
|
|
50
|
-
'/*',
|
|
51
|
-
` * ${p.name}'s settings for the OKJT kit (okjt/laravel-kit). Only what differs`,
|
|
52
|
-
' * from the kit is here; everything else, and what each setting means, is in',
|
|
53
|
-
' * vendor/okjt/laravel-kit/config/okjt.php. Lists are replaced whole.',
|
|
54
|
-
' */',
|
|
55
|
-
"$kit = require base_path('vendor/okjt/laravel-kit/config/okjt.php');",
|
|
56
|
-
'',
|
|
57
|
-
'$config = array_replace_recursive($kit, [',
|
|
58
|
-
" 'workspace' => [",
|
|
59
|
-
` 'model' => App\\Models\\${w.Model}::class,`,
|
|
60
|
-
` 'foreign_key' => ${q(w.foreignKey)},`,
|
|
61
|
-
` 'members_table' => ${q(w.members)},`,
|
|
62
|
-
` 'folder' => ${q(w.table)},`,
|
|
63
|
-
' ],',
|
|
64
|
-
` 'label' => ${q(w.label)},`,
|
|
65
|
-
" 'auth' => [",
|
|
66
|
-
` 'token_name' => ${q(p.slug)},`,
|
|
67
|
-
` 'welcome' => ${q(`Welcome to ${p.name} — check your inbox to verify your email.`)},`,
|
|
68
|
-
' ],',
|
|
69
|
-
" 'collaboration' => [",
|
|
70
|
-
` 'token_name' => ${q(p.slug)},`,
|
|
71
|
-
` 'default_role' => ${q(p.preset === 'simple' ? 'viewer' : 'editor')},`,
|
|
72
|
-
" 'badge_modules' => ['notes'],",
|
|
73
|
-
' ],',
|
|
74
|
-
p.accounts && lines(
|
|
75
|
-
" 'accounts' => [",
|
|
76
|
-
" 'model' => App\\Models\\Account::class,",
|
|
77
|
-
` 'foreign_key' => 'account_id',`,
|
|
78
|
-
' ],',
|
|
79
|
-
),
|
|
80
|
-
p.features.billing && lines(
|
|
81
|
-
" 'billing' => [",
|
|
82
|
-
` 'reference_prefix' => ${q(`${p.slug.replace(/-/g, '_')}_`)},`,
|
|
83
|
-
` 'limit_names' => ['workspaces' => ${q(w.plural)}, 'collaborators' => 'people'],`,
|
|
84
|
-
' ],',
|
|
85
|
-
),
|
|
86
|
-
']);',
|
|
87
|
-
'',
|
|
88
|
-
`$config['workspace']['open_routes'] = [`,
|
|
89
|
-
...open.map((route) => ` ${q(route)},`),
|
|
90
|
-
'];',
|
|
91
|
-
`$config['roles']['all'] = [${roles.map(q).join(', ')}];`,
|
|
92
|
-
"$config['roles']['viewer_writes'] = ['api/logout', 'api/collaboration/heartbeat', 'api/2fa/*', 'api/notifications/mark-read'];",
|
|
93
|
-
p.features.billing && lines(
|
|
94
|
-
'',
|
|
95
|
-
'// Placeholder plans: set real prices before switching BILLING_ENABLED on.',
|
|
96
|
-
"$config['billing']['trial_limits'] = ['workspaces' => 3, 'collaborators' => 3];",
|
|
97
|
-
"$config['billing']['plans'] = [",
|
|
98
|
-
" 'free' => ['name' => 'Free', 'for' => [], 'price' => null, 'period' => null, 'limits' => ['workspaces' => 1, 'collaborators' => 3]],",
|
|
99
|
-
" 'pro' => ['name' => 'Pro', 'for' => [], 'price' => 2500, 'period' => 1, 'limits' => ['workspaces' => 20, 'collaborators' => 20]],",
|
|
100
|
-
'];',
|
|
101
|
-
),
|
|
102
|
-
'',
|
|
103
|
-
'return $config;',
|
|
104
|
-
'',
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function bootstrap() {
|
|
109
|
-
return `<?php
|
|
110
|
-
|
|
111
|
-
use Illuminate\\Auth\\AuthenticationException;
|
|
112
|
-
use Illuminate\\Foundation\\Application;
|
|
113
|
-
use Illuminate\\Foundation\\Configuration\\Exceptions;
|
|
114
|
-
use Illuminate\\Foundation\\Configuration\\Middleware;
|
|
115
|
-
use Illuminate\\Http\\Request;
|
|
116
|
-
use Okjt\\Kit\\Http\\Middleware\\ResolveWorkspace;
|
|
117
|
-
|
|
118
|
-
return Application::configure(basePath: dirname(__DIR__))
|
|
119
|
-
->withRouting(
|
|
120
|
-
web: __DIR__.'/../routes/web.php',
|
|
121
|
-
api: __DIR__.'/../routes/api.php',
|
|
122
|
-
commands: __DIR__.'/../routes/console.php',
|
|
123
|
-
health: '/up',
|
|
124
|
-
)
|
|
125
|
-
->withMiddleware(function (Middleware $middleware): void {
|
|
126
|
-
// Before route-model binding, so bound records are already scoped to the workspace.
|
|
127
|
-
$middleware->api(prepend: [ResolveWorkspace::class]);
|
|
128
|
-
})
|
|
129
|
-
->withExceptions(function (Exceptions $exceptions): void {
|
|
130
|
-
$exceptions->render(function (AuthenticationException $e, Request $request) {
|
|
131
|
-
if ($request->is('api/*') || $request->expectsJson()) {
|
|
132
|
-
return response()->json(['message' => 'Unauthenticated.'], 401);
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
})->create();
|
|
136
|
-
`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function apiRoutes(p) {
|
|
140
|
-
const w = p.workspace;
|
|
141
|
-
const use = [
|
|
142
|
-
`App\\Http\\Controllers\\Api\\AuthController`,
|
|
143
|
-
`App\\Http\\Controllers\\Api\\NoteController`,
|
|
144
|
-
`App\\Http\\Controllers\\Api\\${w.Model}Controller`,
|
|
145
|
-
'Illuminate\\Support\\Facades\\Route',
|
|
146
|
-
p.features.backups && 'Okjt\\Kit\\Http\\Controllers\\BackupController',
|
|
147
|
-
p.features.billing && 'Okjt\\Kit\\Http\\Controllers\\BillingController',
|
|
148
|
-
'Okjt\\Kit\\Http\\Controllers\\CollaborationController',
|
|
149
|
-
p.features.console && 'Okjt\\Kit\\Http\\Controllers\\ConsoleController',
|
|
150
|
-
'Okjt\\Kit\\Http\\Controllers\\EmailVerificationController',
|
|
151
|
-
'Okjt\\Kit\\Http\\Controllers\\NotificationController',
|
|
152
|
-
'Okjt\\Kit\\Http\\Controllers\\RevisionController',
|
|
153
|
-
p.features.google && 'Okjt\\Kit\\Http\\Controllers\\SocialAuthController',
|
|
154
|
-
p.accounts && 'Okjt\\Kit\\Http\\Controllers\\TeamController',
|
|
155
|
-
'Okjt\\Kit\\Http\\Controllers\\TwoFactorController',
|
|
156
|
-
'Okjt\\Kit\\Http\\Middleware\\EnforceRole',
|
|
157
|
-
p.platform && (p.features.console || p.features.backups) && 'Okjt\\Kit\\Http\\Middleware\\EnsureSuperadmin',
|
|
158
|
-
].filter(Boolean).sort((a, b) => a.localeCompare(b));
|
|
159
|
-
|
|
160
|
-
return lines(
|
|
161
|
-
'<?php',
|
|
162
|
-
'',
|
|
163
|
-
...use.map((c) => `use ${c};`),
|
|
164
|
-
'',
|
|
165
|
-
'/*',
|
|
166
|
-
' * Without a workspace (see open_routes in config/okjt.php): signing in and',
|
|
167
|
-
` * up, your account, your ${w.plural}${p.accounts ? ', your team' : ''}${p.features.billing ? ', billing' : ''}${p.features.console ? ', the console' : ''}.`,
|
|
168
|
-
` * Everything else works on the ${w.label} the request names (X-Workspace).`,
|
|
169
|
-
' */',
|
|
170
|
-
"Route::middleware('throttle:15,1')->group(function () {",
|
|
171
|
-
" Route::post('/login', [AuthController::class, 'login']);",
|
|
172
|
-
" Route::post('/verify-2fa', [AuthController::class, 'verify2fa']);",
|
|
173
|
-
" Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:5,1,register');",
|
|
174
|
-
" Route::post('/email/verify', [EmailVerificationController::class, 'verify']);",
|
|
175
|
-
" Route::post('/email/resend', [EmailVerificationController::class, 'resend']);",
|
|
176
|
-
" Route::post('/collaborators/accept', [CollaborationController::class, 'accept']);",
|
|
177
|
-
p.features.google && " Route::post('/auth/google', [SocialAuthController::class, 'login']);",
|
|
178
|
-
'});',
|
|
179
|
-
p.features.billing && lines(
|
|
180
|
-
'',
|
|
181
|
-
"Route::get('/plans', [BillingController::class, 'plans']);",
|
|
182
|
-
"Route::post('/paystack/webhook', [BillingController::class, 'webhook']);",
|
|
183
|
-
),
|
|
184
|
-
'',
|
|
185
|
-
"Route::middleware(['auth:sanctum', EnforceRole::class])->group(function () {",
|
|
186
|
-
" Route::post('/logout', [AuthController::class, 'logout']);",
|
|
187
|
-
" Route::get('/user', [AuthController::class, 'user']);",
|
|
188
|
-
" Route::post('/2fa/setup', [TwoFactorController::class, 'setup']);",
|
|
189
|
-
" Route::post('/2fa/confirm', [TwoFactorController::class, 'confirm']);",
|
|
190
|
-
" Route::post('/2fa/disable', [TwoFactorController::class, 'disable']);",
|
|
191
|
-
'',
|
|
192
|
-
` Route::get('/${w.path}', [${w.Model}Controller::class, 'index']);`,
|
|
193
|
-
` Route::post('/${w.path}', [${w.Model}Controller::class, 'store'])->middleware('throttle:10,1,new-workspace');`,
|
|
194
|
-
p.accounts && lines(
|
|
195
|
-
'',
|
|
196
|
-
" Route::get('/team', [TeamController::class, 'index']);",
|
|
197
|
-
" Route::post('/team', [TeamController::class, 'invite'])->middleware('throttle:20,1,team');",
|
|
198
|
-
" Route::put('/team/{user}', [TeamController::class, 'update']);",
|
|
199
|
-
" Route::delete('/team/{user}', [TeamController::class, 'destroy']);",
|
|
200
|
-
),
|
|
201
|
-
p.features.billing && lines(
|
|
202
|
-
'',
|
|
203
|
-
" Route::get('/billing', [BillingController::class, 'show']);",
|
|
204
|
-
" Route::post('/billing/checkout', [BillingController::class, 'checkout'])->middleware('throttle:10,1,checkout');",
|
|
205
|
-
" Route::get('/billing/verify', [BillingController::class, 'verify']);",
|
|
206
|
-
),
|
|
207
|
-
'',
|
|
208
|
-
` // Inside a ${w.label}: working together, version history, the bell, and its notes.`,
|
|
209
|
-
" Route::post('/collaboration/heartbeat', [CollaborationController::class, 'heartbeat']);",
|
|
210
|
-
" Route::get('/collaborators', [CollaborationController::class, 'collaborators']);",
|
|
211
|
-
" Route::middleware(EnforceRole::class.':owner')->group(function () {",
|
|
212
|
-
" Route::post('/collaborators/invite', [CollaborationController::class, 'invite']);",
|
|
213
|
-
" Route::patch('/collaborators/{user}/role', [CollaborationController::class, 'updateRole']);",
|
|
214
|
-
" Route::delete('/collaborators/{user}', [CollaborationController::class, 'remove']);",
|
|
215
|
-
' });',
|
|
216
|
-
" Route::get('/revisions', [RevisionController::class, 'index']);",
|
|
217
|
-
" Route::post('/revisions/{revision}/restore', [RevisionController::class, 'restore']);",
|
|
218
|
-
" Route::get('/notifications', [NotificationController::class, 'index']);",
|
|
219
|
-
" Route::post('/notifications/mark-read', [NotificationController::class, 'markAllRead']);",
|
|
220
|
-
'',
|
|
221
|
-
" Route::get('/notes', [NoteController::class, 'index']);",
|
|
222
|
-
" Route::post('/notes', [NoteController::class, 'store']);",
|
|
223
|
-
" Route::put('/notes/{note}', [NoteController::class, 'update']);",
|
|
224
|
-
" Route::delete('/notes/{note}', [NoteController::class, 'destroy']);",
|
|
225
|
-
p.features.console && lines(
|
|
226
|
-
'',
|
|
227
|
-
" Route::prefix('console')->middleware(EnsureSuperadmin::class)->group(function () {",
|
|
228
|
-
" Route::get('/workspaces', [ConsoleController::class, 'workspaces']);",
|
|
229
|
-
" Route::patch('/workspaces/{workspace}/status', [ConsoleController::class, 'setStatus']);",
|
|
230
|
-
" Route::delete('/workspaces/{workspace}', [ConsoleController::class, 'destroyWorkspace']);",
|
|
231
|
-
" Route::get('/audit', [ConsoleController::class, 'audit']);",
|
|
232
|
-
' });',
|
|
233
|
-
),
|
|
234
|
-
p.features.backups && lines(
|
|
235
|
-
'',
|
|
236
|
-
" Route::prefix('backups')->middleware(EnsureSuperadmin::class)->group(function () {",
|
|
237
|
-
" Route::get('/', [BackupController::class, 'index']);",
|
|
238
|
-
" Route::post('/', [BackupController::class, 'store']);",
|
|
239
|
-
" Route::get('/{name}/download', [BackupController::class, 'download']);",
|
|
240
|
-
" Route::post('/{name}/restore', [BackupController::class, 'restore']);",
|
|
241
|
-
" Route::post('/{name}/verify', [BackupController::class, 'verify']);",
|
|
242
|
-
" Route::delete('/{name}', [BackupController::class, 'destroy']);",
|
|
243
|
-
' });',
|
|
244
|
-
),
|
|
245
|
-
'});',
|
|
246
|
-
'',
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function webRoutes(p) {
|
|
251
|
-
const web = p.features.google || p.features.passkeys;
|
|
252
|
-
return lines(
|
|
253
|
-
'<?php',
|
|
254
|
-
'',
|
|
255
|
-
p.features.passkeys && 'use Okjt\\Kit\\Http\\Controllers\\PasskeyController;',
|
|
256
|
-
p.features.google && 'use Okjt\\Kit\\Http\\Controllers\\SocialAuthController;',
|
|
257
|
-
'use Illuminate\\Support\\Facades\\Route;',
|
|
258
|
-
'',
|
|
259
|
-
web && '// Passkey challenges and Google\'s OAuth state ride the session, so these are web routes.',
|
|
260
|
-
p.features.passkeys && lines(
|
|
261
|
-
"Route::prefix('api')->middleware(['web', 'throttle:6,1'])->group(function () {",
|
|
262
|
-
" Route::get('/passkeys/login/options', [PasskeyController::class, 'loginOptions']);",
|
|
263
|
-
" Route::post('/passkeys/login', [PasskeyController::class, 'login']);",
|
|
264
|
-
" Route::get('/passkeys/options', [PasskeyController::class, 'registerOptions'])->middleware('auth:sanctum');",
|
|
265
|
-
" Route::get('/passkeys', [PasskeyController::class, 'index'])->middleware('auth:sanctum');",
|
|
266
|
-
" Route::post('/passkeys', [PasskeyController::class, 'register'])->middleware('auth:sanctum');",
|
|
267
|
-
" Route::delete('/passkeys/{id}', [PasskeyController::class, 'destroy'])->middleware('auth:sanctum');",
|
|
268
|
-
'});',
|
|
269
|
-
'',
|
|
270
|
-
),
|
|
271
|
-
p.features.google && lines(
|
|
272
|
-
"Route::prefix('api')->middleware(['web', 'throttle:20,1'])->group(function () {",
|
|
273
|
-
" Route::get('/auth/google', [SocialAuthController::class, 'redirect']);",
|
|
274
|
-
" Route::get('/auth/google/callback', [SocialAuthController::class, 'callback']);",
|
|
275
|
-
'});',
|
|
276
|
-
'',
|
|
277
|
-
),
|
|
278
|
-
'// The React app (built into public/ when deployed) answers every other page.',
|
|
279
|
-
"Route::get('/{any?}', function () {",
|
|
280
|
-
" $index = public_path('index.html');",
|
|
281
|
-
'',
|
|
282
|
-
" return file_exists($index) ? response()->file($index) : response('Run the frontend: cd frontend && npm run dev', 200);",
|
|
283
|
-
"})->where('any', '^(?!api|up|storage|assets).*$');",
|
|
284
|
-
'',
|
|
285
|
-
'// Where Laravel sends people it can\'t authenticate (the API answers 401 instead).',
|
|
286
|
-
"Route::get('/login', fn () => response()->json(['message' => 'Unauthenticated.'], 401))->name('login');",
|
|
287
|
-
'',
|
|
288
|
-
);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function userModel(p) {
|
|
292
|
-
const passkeys = p.features.passkeys;
|
|
293
|
-
return lines(
|
|
294
|
-
'<?php',
|
|
295
|
-
'',
|
|
296
|
-
'namespace App\\Models;',
|
|
297
|
-
'',
|
|
298
|
-
'use
|
|
299
|
-
'use Illuminate\\
|
|
300
|
-
'use Illuminate\\
|
|
301
|
-
'use Illuminate\\
|
|
302
|
-
'use
|
|
303
|
-
passkeys && 'use Laravel\\Passkeys\\
|
|
304
|
-
|
|
305
|
-
'use
|
|
306
|
-
|
|
307
|
-
'use Okjt\\Kit\\Workspace\\HasWorkspaceRoles;',
|
|
308
|
-
'',
|
|
309
|
-
'/**',
|
|
310
|
-
` * Someone who signs in to ${p.name}. Their role is per ${p.workspace.label}`,
|
|
311
|
-
` * (HasWorkspaceRoles: \`role\` is their role in the current one)${p.accounts ? ', and they own or work for accounts (HasAccounts)' : ''}.`,
|
|
312
|
-
p.platform && ' * `is_superadmin` marks the people who run the platform.',
|
|
313
|
-
' */',
|
|
314
|
-
`class User extends Authenticatable implements MustVerifyEmail${passkeys ? ', PasskeyUser' : ''}`,
|
|
315
|
-
'{',
|
|
316
|
-
` use ${[p.accounts && 'HasAccounts', 'HasApiTokens', 'HasFactory', 'HasWorkspaceRoles', 'Notifiable', passkeys && 'PasskeyAuthenticatable'].filter(Boolean).join(', ')};`,
|
|
317
|
-
'',
|
|
318
|
-
" protected $fillable = ['name', 'email', 'password', 'role'];",
|
|
319
|
-
'',
|
|
320
|
-
" protected $hidden = ['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'];",
|
|
321
|
-
'',
|
|
322
|
-
' protected function casts(): array',
|
|
323
|
-
' {',
|
|
324
|
-
' return [',
|
|
325
|
-
" 'email_verified_at' => 'datetime',",
|
|
326
|
-
" 'password' => 'hashed',",
|
|
327
|
-
" 'is_superadmin' => 'boolean',",
|
|
328
|
-
" 'two_factor_confirmed_at' => 'datetime',",
|
|
329
|
-
" 'last_active_at' => 'datetime',",
|
|
330
|
-
" 'invite_accepted_at' => 'datetime',",
|
|
331
|
-
' ];',
|
|
332
|
-
' }',
|
|
333
|
-
'',
|
|
334
|
-
' /** The verification email
|
|
335
|
-
' public function sendEmailVerificationNotification(): void',
|
|
336
|
-
' {',
|
|
337
|
-
' $this->notify(new
|
|
338
|
-
' }',
|
|
339
|
-
'}',
|
|
340
|
-
'',
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function workspaceModel(p) {
|
|
345
|
-
const w = p.workspace;
|
|
346
|
-
return lines(
|
|
347
|
-
'<?php',
|
|
348
|
-
'',
|
|
349
|
-
'namespace App\\Models;',
|
|
350
|
-
'',
|
|
351
|
-
'use Illuminate\\Database\\Eloquent\\Model;',
|
|
352
|
-
p.accounts && 'use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;',
|
|
353
|
-
'use Okjt\\Kit\\Workspace\\Workspace;',
|
|
354
|
-
'',
|
|
355
|
-
`/** A ${w.label}: what people work on together (the kit's workspace). Requests name it by its slug. */`,
|
|
356
|
-
`class ${w.Model} extends Model implements Workspace`,
|
|
357
|
-
'{',
|
|
358
|
-
` protected $fillable = ['name', 'slug', 'status'${p.accounts ? ", 'account_id'" : ''}];`,
|
|
359
|
-
'',
|
|
360
|
-
' public static function findByKey(?string $key): ?self',
|
|
361
|
-
' {',
|
|
362
|
-
" return $key ? static::where('slug', $key)->first() : null;",
|
|
363
|
-
' }',
|
|
364
|
-
'',
|
|
365
|
-
' public function isActive(): bool',
|
|
366
|
-
' {',
|
|
367
|
-
" return $this->status === 'active';",
|
|
368
|
-
' }',
|
|
369
|
-
'',
|
|
370
|
-
` /** ${w.plural} have no public pages, so there's nothing to publish. */`,
|
|
371
|
-
' public function isPublished(): bool',
|
|
372
|
-
' {',
|
|
373
|
-
' return true;',
|
|
374
|
-
' }',
|
|
375
|
-
p.accounts && lines(
|
|
376
|
-
'',
|
|
377
|
-
' public function account(): BelongsTo',
|
|
378
|
-
' {',
|
|
379
|
-
' return $this->belongsTo(Account::class);',
|
|
380
|
-
' }',
|
|
381
|
-
),
|
|
382
|
-
'}',
|
|
383
|
-
'',
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function accountModel(p) {
|
|
388
|
-
return lines(
|
|
389
|
-
'<?php',
|
|
390
|
-
'',
|
|
391
|
-
'namespace App\\Models;',
|
|
392
|
-
'',
|
|
393
|
-
'use Illuminate\\Database\\Eloquent\\Model;',
|
|
394
|
-
'use Okjt\\Kit\\Accounts\\HasTeam;',
|
|
395
|
-
'',
|
|
396
|
-
`/** A customer: owns ${p.workspace.plural}; its people are owners or staff (HasTeam)${p.features.billing ? ', and it has a plan' : ''}. */`,
|
|
397
|
-
'class Account extends Model',
|
|
398
|
-
'{',
|
|
399
|
-
' use HasTeam;',
|
|
400
|
-
'',
|
|
401
|
-
` protected $fillable = ['name'${p.features.billing ? ", 'plan', 'plan_expires_at'" : ''}];`,
|
|
402
|
-
p.features.billing && lines(
|
|
403
|
-
'',
|
|
404
|
-
' protected function casts(): array',
|
|
405
|
-
' {',
|
|
406
|
-
" return ['plan_expires_at' => 'datetime'];",
|
|
407
|
-
' }',
|
|
408
|
-
),
|
|
409
|
-
'}',
|
|
410
|
-
'',
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function noteModel() {
|
|
415
|
-
return `<?php
|
|
416
|
-
|
|
417
|
-
namespace App\\Models;
|
|
418
|
-
|
|
419
|
-
use Illuminate\\Database\\Eloquent\\Model;
|
|
420
|
-
use Okjt\\Kit\\Revisions\\RecordsRevisions;
|
|
421
|
-
use Okjt\\Kit\\Workspace\\BelongsToWorkspace;
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* The starter module: notes in a workspace. Copy it for your own: scoped to
|
|
425
|
-
* the current workspace (BelongsToWorkspace), every change kept in version
|
|
426
|
-
* history (RecordsRevisions), under the dashboard module "notes".
|
|
427
|
-
*/
|
|
428
|
-
class Note extends Model
|
|
429
|
-
{
|
|
430
|
-
use BelongsToWorkspace, RecordsRevisions;
|
|
431
|
-
|
|
432
|
-
protected string $revisionModule = 'notes';
|
|
433
|
-
|
|
434
|
-
protected $fillable = ['title', 'body'];
|
|
435
|
-
}
|
|
436
|
-
`;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function authController(p) {
|
|
440
|
-
return lines(
|
|
441
|
-
'<?php',
|
|
442
|
-
'',
|
|
443
|
-
'namespace App\\Http\\Controllers\\Api;',
|
|
444
|
-
'',
|
|
445
|
-
p.accounts && 'use App\\Models\\Account;',
|
|
446
|
-
p.accounts && 'use Illuminate\\Database\\Eloquent\\Model;',
|
|
447
|
-
'use Okjt\\Kit\\Http\\Controllers\\AuthController as KitAuthController;',
|
|
448
|
-
'',
|
|
449
|
-
'/**',
|
|
450
|
-
' * Signing in, two-step codes, signing up, signing out and who you are: the',
|
|
451
|
-
` * OKJT kit's.${p.accounts ? ' Signing up also makes your account, which you own.' : ''}`,
|
|
452
|
-
' */',
|
|
453
|
-
'class AuthController extends KitAuthController',
|
|
454
|
-
'{',
|
|
455
|
-
p.accounts && lines(
|
|
456
|
-
' protected function registered(Model $user, array $validated): array',
|
|
457
|
-
' {',
|
|
458
|
-
" $account = Account::create(['name' => $user->name]);",
|
|
459
|
-
" $account->members()->attach($user->id, ['role' => config('okjt.accounts.owner_role')]);",
|
|
460
|
-
'',
|
|
461
|
-
" return ['account' => ['id' => $account->id, 'name' => $account->name]];",
|
|
462
|
-
' }',
|
|
463
|
-
),
|
|
464
|
-
'}',
|
|
465
|
-
'',
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function workspaceController(p) {
|
|
470
|
-
const w = p.workspace;
|
|
471
|
-
return lines(
|
|
472
|
-
'<?php',
|
|
473
|
-
'',
|
|
474
|
-
'namespace App\\Http\\Controllers\\Api;',
|
|
475
|
-
'',
|
|
476
|
-
`use App\\Models\\${w.Model};`,
|
|
477
|
-
'use Illuminate\\Http\\JsonResponse;',
|
|
478
|
-
'use Illuminate\\Http\\Request;',
|
|
479
|
-
'use Illuminate\\Routing\\Controller;',
|
|
480
|
-
'use Illuminate\\Support\\Facades\\DB;',
|
|
481
|
-
'use Illuminate\\Support\\Str;',
|
|
482
|
-
p.features.billing && 'use Okjt\\Kit\\Billing\\Limits;',
|
|
483
|
-
'use Okjt\\Kit\\Support\\ApiResponse;',
|
|
484
|
-
'',
|
|
485
|
-
`/** Your ${w.plural}: the ones you can open (with your role), and starting a new one. */`,
|
|
486
|
-
`class ${w.Model}Controller extends Controller`,
|
|
487
|
-
'{',
|
|
488
|
-
' use ApiResponse;',
|
|
489
|
-
'',
|
|
490
|
-
' public function index(Request $request): JsonResponse',
|
|
491
|
-
' {',
|
|
492
|
-
` $rows = DB::table(${q(w.members)})`,
|
|
493
|
-
` ->join(${q(w.table)}, ${q(`${w.table}.id`)}, '=', ${q(`${w.members}.${w.foreignKey}`)})`,
|
|
494
|
-
` ->where(${q(`${w.members}.user_id`)}, $request->user()->id)`,
|
|
495
|
-
` ->orderBy(${q(`${w.table}.name`)})`,
|
|
496
|
-
` ->get([${q(`${w.table}.id`)}, ${q(`${w.table}.name`)}, ${q(`${w.table}.slug`)}, ${q(`${w.table}.status`)}, ${q(`${w.members}.role`)}]);`,
|
|
497
|
-
'',
|
|
498
|
-
' return $this->successResponse($rows);',
|
|
499
|
-
' }',
|
|
500
|
-
'',
|
|
501
|
-
' public function store(Request $request): JsonResponse',
|
|
502
|
-
' {',
|
|
503
|
-
" $validated = $request->validate(['name' => 'required|string|max:120']);",
|
|
504
|
-
p.accounts && lines(
|
|
505
|
-
' $account = $request->user()->ownedAccount();',
|
|
506
|
-
' if (! $account) {',
|
|
507
|
-
` return $this->forbiddenResponse(${q(`Only an account's owners start new ${w.plural}.`)});`,
|
|
508
|
-
' }',
|
|
509
|
-
p.features.billing && " Limits::ensure('workspaces', 1, $account);",
|
|
510
|
-
),
|
|
511
|
-
'',
|
|
512
|
-
" $slug = Str::slug($validated['name']) ?: Str::lower(Str::random(8));",
|
|
513
|
-
` while (${w.Model}::where('slug', $slug)->exists()) {`,
|
|
514
|
-
" $slug = Str::slug($validated['name']).'-'.Str::lower(Str::random(4));",
|
|
515
|
-
' }',
|
|
516
|
-
'',
|
|
517
|
-
` $workspace = ${w.Model}::create(['name' => $validated['name'], 'slug' => $slug, 'status' => 'active'${p.accounts ? ", 'account_id' => $account->id" : ''}]);`,
|
|
518
|
-
" $request->user()->setRoleIn($workspace, config('okjt.roles.owner'));",
|
|
519
|
-
p.accounts && ' $account->shareWithTeam($workspace);',
|
|
520
|
-
'',
|
|
521
|
-
" return $this->createdResponse($workspace->only(['id', 'name', 'slug', 'status']) + ['role' => config('okjt.roles.owner')]);",
|
|
522
|
-
' }',
|
|
523
|
-
'}',
|
|
524
|
-
'',
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function noteController() {
|
|
529
|
-
return `<?php
|
|
530
|
-
|
|
531
|
-
namespace App\\Http\\Controllers\\Api;
|
|
532
|
-
|
|
533
|
-
use App\\Models\\Note;
|
|
534
|
-
use Illuminate\\Http\\JsonResponse;
|
|
535
|
-
use Illuminate\\Http\\Request;
|
|
536
|
-
use Illuminate\\Routing\\Controller;
|
|
537
|
-
use Okjt\\Kit\\Support\\ApiResponse;
|
|
538
|
-
|
|
539
|
-
/** The starter module's API: the current workspace's notes (viewers may only read; EnforceRole). */
|
|
540
|
-
class NoteController extends Controller
|
|
541
|
-
{
|
|
542
|
-
use ApiResponse;
|
|
543
|
-
|
|
544
|
-
public function index(): JsonResponse
|
|
545
|
-
{
|
|
546
|
-
return $this->successResponse(Note::latest('updated_at')->get());
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
public function store(Request $request): JsonResponse
|
|
550
|
-
{
|
|
551
|
-
return $this->createdResponse(Note::create($this->validated($request)), 'Note added');
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
public function update(Request $request, Note $note): JsonResponse
|
|
555
|
-
{
|
|
556
|
-
$note->update($this->validated($request));
|
|
557
|
-
|
|
558
|
-
return $this->successResponse($note, 'Note saved');
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
public function destroy(Note $note): JsonResponse
|
|
562
|
-
{
|
|
563
|
-
$note->delete();
|
|
564
|
-
|
|
565
|
-
return $this->deletedResponse('Note deleted');
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
private function validated(Request $request): array
|
|
569
|
-
{
|
|
570
|
-
return $request->validate(['title' => 'required|string|max:160', 'body' => 'nullable|string|max:10000']);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
`;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
function usersMigration() {
|
|
577
|
-
return `<?php
|
|
578
|
-
|
|
579
|
-
use Illuminate\\Database\\Migrations\\Migration;
|
|
580
|
-
use Illuminate\\Database\\Schema\\Blueprint;
|
|
581
|
-
use Illuminate\\Support\\Facades\\Schema;
|
|
582
|
-
|
|
583
|
-
/** What the kit reads on users beyond its own migrations: a role, and two-step verification. */
|
|
584
|
-
return new class extends Migration
|
|
585
|
-
{
|
|
586
|
-
public function up(): void
|
|
587
|
-
{
|
|
588
|
-
Schema::table('users', function (Blueprint $table) {
|
|
589
|
-
$table->string('role', 20)->nullable();
|
|
590
|
-
$table->text('two_factor_secret')->nullable();
|
|
591
|
-
$table->text('two_factor_recovery_codes')->nullable();
|
|
592
|
-
$table->timestamp('two_factor_confirmed_at')->nullable();
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
public function down(): void
|
|
597
|
-
{
|
|
598
|
-
Schema::table('users', fn (Blueprint $table) => $table->dropColumn(['role', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at']));
|
|
599
|
-
}
|
|
600
|
-
};
|
|
601
|
-
`;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
function workspaceMigration(p) {
|
|
605
|
-
const w = p.workspace;
|
|
606
|
-
return lines(
|
|
607
|
-
'<?php',
|
|
608
|
-
'',
|
|
609
|
-
'use Illuminate\\Database\\Migrations\\Migration;',
|
|
610
|
-
'use Illuminate\\Database\\Schema\\Blueprint;',
|
|
611
|
-
'use Illuminate\\Support\\Facades\\Schema;',
|
|
612
|
-
'',
|
|
613
|
-
`/** ${w.plural}: what people work on together. */`,
|
|
614
|
-
'return new class extends Migration',
|
|
615
|
-
'{',
|
|
616
|
-
' public function up(): void',
|
|
617
|
-
' {',
|
|
618
|
-
` Schema::create(${q(w.table)}, function (Blueprint $table) {`,
|
|
619
|
-
' $table->id();',
|
|
620
|
-
p.accounts && " $table->unsignedBigInteger('account_id')->nullable()->index();",
|
|
621
|
-
" $table->string('name');",
|
|
622
|
-
" $table->string('slug', 80)->unique();",
|
|
623
|
-
" $table->string('status', 20)->default('active'); // active | suspended | archived",
|
|
624
|
-
' $table->timestamps();',
|
|
625
|
-
' });',
|
|
626
|
-
' }',
|
|
627
|
-
'',
|
|
628
|
-
' public function down(): void',
|
|
629
|
-
' {',
|
|
630
|
-
` Schema::dropIfExists(${q(w.table)});`,
|
|
631
|
-
' }',
|
|
632
|
-
'};',
|
|
633
|
-
'',
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function notesMigration(p) {
|
|
638
|
-
return lines(
|
|
639
|
-
'<?php',
|
|
640
|
-
'',
|
|
641
|
-
'use Illuminate\\Database\\Migrations\\Migration;',
|
|
642
|
-
'use Illuminate\\Database\\Schema\\Blueprint;',
|
|
643
|
-
'use Illuminate\\Support\\Facades\\Schema;',
|
|
644
|
-
'',
|
|
645
|
-
'/** The starter module: notes, each in one workspace. */',
|
|
646
|
-
'return new class extends Migration',
|
|
647
|
-
'{',
|
|
648
|
-
' public function up(): void',
|
|
649
|
-
' {',
|
|
650
|
-
" Schema::create('notes', function (Blueprint $table) {",
|
|
651
|
-
' $table->id();',
|
|
652
|
-
` $table->unsignedBigInteger(${q(p.workspace.foreignKey)})->index();`,
|
|
653
|
-
" $table->string('title');",
|
|
654
|
-
" $table->text('body')->nullable();",
|
|
655
|
-
' $table->timestamps();',
|
|
656
|
-
' });',
|
|
657
|
-
' }',
|
|
658
|
-
'',
|
|
659
|
-
' public function down(): void',
|
|
660
|
-
' {',
|
|
661
|
-
" Schema::dropIfExists('notes');",
|
|
662
|
-
' }',
|
|
663
|
-
'};',
|
|
664
|
-
'',
|
|
665
|
-
);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
function seeder(p) {
|
|
669
|
-
return lines(
|
|
670
|
-
'<?php',
|
|
671
|
-
'',
|
|
672
|
-
'namespace Database\\Seeders;',
|
|
673
|
-
'',
|
|
674
|
-
p.accounts && 'use App\\Models\\Account;',
|
|
675
|
-
'use App\\Models\\User;',
|
|
676
|
-
'use Illuminate\\Database\\Seeder;',
|
|
677
|
-
'',
|
|
678
|
-
'/**',
|
|
679
|
-
` * The first person who can sign in: ${p.platform ? 'a superadmin' : 'an admin'}, verified, from`,
|
|
680
|
-
' * OKJT_ADMIN_EMAIL / OKJT_ADMIN_PASSWORD in .env. Run once: php artisan db:seed',
|
|
681
|
-
' */',
|
|
682
|
-
'class DatabaseSeeder extends Seeder',
|
|
683
|
-
'{',
|
|
684
|
-
' public function run(): void',
|
|
685
|
-
' {',
|
|
686
|
-
" $email =
|
|
687
|
-
" $password =
|
|
688
|
-
' if (! $email || ! $password) {',
|
|
689
|
-
" $this->command?->warn('Set OKJT_ADMIN_EMAIL and OKJT_ADMIN_PASSWORD in .env to create the first user.');",
|
|
690
|
-
'',
|
|
691
|
-
' return;',
|
|
692
|
-
' }',
|
|
693
|
-
'',
|
|
694
|
-
' $user = User::firstOrNew([\'email\' => $email]);',
|
|
695
|
-
" $user->forceFill([",
|
|
696
|
-
|
|
697
|
-
" 'password' => $password,",
|
|
698
|
-
" 'role' => config('okjt.roles.owner'),",
|
|
699
|
-
" 'email_verified_at' => now(),",
|
|
700
|
-
p.platform && " 'is_superadmin' => true,",
|
|
701
|
-
' ])->save();',
|
|
702
|
-
p.accounts && lines(
|
|
703
|
-
'',
|
|
704
|
-
' if (! $user->ownedAccount()) {',
|
|
705
|
-
" Account::create(['name' => $user->name])->members()->attach($user->id, ['role' => config('okjt.accounts.owner_role')]);",
|
|
706
|
-
' }',
|
|
707
|
-
),
|
|
708
|
-
' }',
|
|
709
|
-
'}',
|
|
710
|
-
'',
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
function starterTest(p) {
|
|
715
|
-
const w = p.workspace;
|
|
716
|
-
return lines(
|
|
717
|
-
'<?php',
|
|
718
|
-
'',
|
|
719
|
-
'namespace Tests\\Feature;',
|
|
720
|
-
'',
|
|
721
|
-
p.accounts && 'use App\\Models\\Account;',
|
|
722
|
-
'use App\\Models\\User;',
|
|
723
|
-
'use Illuminate\\Foundation\\Testing\\RefreshDatabase;',
|
|
724
|
-
'use Tests\\TestCase;',
|
|
725
|
-
'',
|
|
726
|
-
`/** The starter works end to end: signing up and in, a ${w.label}, its notes and their history, and roles. */`,
|
|
727
|
-
'class StarterTest extends TestCase',
|
|
728
|
-
'{',
|
|
729
|
-
' use RefreshDatabase;',
|
|
730
|
-
'',
|
|
731
|
-
' private function person(string $email): User',
|
|
732
|
-
' {',
|
|
733
|
-
" $user = User::create(['name' => ucfirst(strtok($email, '@')), 'email' => $email, 'password' => 'secret-password']);",
|
|
734
|
-
" $user->forceFill(['email_verified_at' => now()])->save();",
|
|
735
|
-
p.accounts && " Account::create(['name' => $user->name])->members()->attach($user->id, ['role' => 'owner']);",
|
|
736
|
-
'',
|
|
737
|
-
' return $user;',
|
|
738
|
-
' }',
|
|
739
|
-
'',
|
|
740
|
-
' public function test_signing_up_asks_for_email_verification_first(): void',
|
|
741
|
-
' {',
|
|
742
|
-
" $this->postJson('/api/register', ['name' => 'Ann', 'email' => 'ann@example.com', 'password' => 'secret-password', 'password_confirmation' => 'secret-password'])",
|
|
743
|
-
" ->assertCreated()->assertJsonPath('data.requires_verification', true);",
|
|
744
|
-
'',
|
|
745
|
-
" $this->postJson('/api/login', ['email' => 'ann@example.com', 'password' => 'secret-password'])",
|
|
746
|
-
" ->assertOk()->assertJsonPath('data.requires_verification', true);",
|
|
747
|
-
' }',
|
|
748
|
-
'',
|
|
749
|
-
' public function test_a_verified_person_signs_in(): void',
|
|
750
|
-
' {',
|
|
751
|
-
" $this->person('bo@example.com');",
|
|
752
|
-
'',
|
|
753
|
-
" $this->postJson('/api/login', ['email' => 'bo@example.com', 'password' => 'secret-password'])",
|
|
754
|
-
" ->assertOk()->assertJsonPath('data.user.email', 'bo@example.com')",
|
|
755
|
-
" ->assertJsonPath('data.token', fn ($token) => ! empty($token));",
|
|
756
|
-
' }',
|
|
757
|
-
'',
|
|
758
|
-
` public function test_a_${w.label.replace(/ /g, '_')}_has_notes_with_history_and_viewers_only_read(): void`,
|
|
759
|
-
' {',
|
|
760
|
-
" $owner = $this->person('owner@example.com');",
|
|
761
|
-
` $slug = $this->actingAs($owner, 'sanctum')->postJson('/api/${w.path}', ['name' => 'First one'])->assertCreated()->json('data.slug');`,
|
|
762
|
-
` $this->getJson('/api/${w.path}')->assertJsonPath('data.0.role', 'owner');`,
|
|
763
|
-
'',
|
|
764
|
-
" $headers = ['X-Workspace' => $slug];",
|
|
765
|
-
" $note = $this->postJson('/api/notes', ['title' => 'Kick-off'], $headers)->assertCreated()->json('data');",
|
|
766
|
-
" $this->putJson(\"/api/notes/{$note['id']}\", ['title' => 'Kick-off, Monday'], $headers)->assertOk();",
|
|
767
|
-
" $this->getJson('/api/revisions', $headers)->assertOk()->assertJsonPath('data.data.0.module', 'notes');",
|
|
768
|
-
'',
|
|
769
|
-
" $viewer = $this->person('viewer@example.com');",
|
|
770
|
-
` $viewer->setRoleIn(\\App\\Models\\${w.Model}::where('slug', $slug)->first(), 'viewer');`,
|
|
771
|
-
" $this->actingAs($viewer, 'sanctum')->getJson('/api/notes', $headers)->assertOk()->assertJsonCount(1, 'data');",
|
|
772
|
-
" $this->postJson('/api/notes', ['title' => 'Mine'], $headers)->assertForbidden();",
|
|
773
|
-
'',
|
|
774
|
-
" $stranger = $this->person('stranger@example.com');",
|
|
775
|
-
" $this->actingAs($stranger, 'sanctum')->getJson('/api/notes', $headers)->assertForbidden();",
|
|
776
|
-
' }',
|
|
777
|
-
'}',
|
|
778
|
-
'',
|
|
779
|
-
);
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
function
|
|
783
|
-
return
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
use Illuminate\\
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
{
|
|
796
|
-
|
|
797
|
-
{
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
function
|
|
807
|
-
|
|
808
|
-
'
|
|
809
|
-
'',
|
|
810
|
-
'
|
|
811
|
-
'
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
'
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
"
|
|
824
|
-
"
|
|
825
|
-
|
|
826
|
-
'
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
'',
|
|
830
|
-
' public function down(): void',
|
|
831
|
-
' {',
|
|
832
|
-
" Schema::dropIfExists('notifications');",
|
|
833
|
-
' }',
|
|
834
|
-
'};',
|
|
835
|
-
'',
|
|
836
|
-
);
|
|
837
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The Laravel API: files written over a fresh `laravel/laravel` install, as
|
|
3
|
+
* path → contents. `p` is the project (answers.mjs › describeProject).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const lines = (...parts) => parts.filter((part) => part !== false && part != null).join('\n');
|
|
7
|
+
const q = (text) => `'${String(text).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
8
|
+
|
|
9
|
+
export function backendFiles(p) {
|
|
10
|
+
const files = {
|
|
11
|
+
'config/okjt.php': okjtConfig(p),
|
|
12
|
+
'bootstrap/app.php': bootstrap(),
|
|
13
|
+
'routes/api.php': apiRoutes(p),
|
|
14
|
+
'routes/web.php': webRoutes(p),
|
|
15
|
+
'app/Models/User.php': userModel(p),
|
|
16
|
+
[`app/Models/${p.workspace.Model}.php`]: workspaceModel(p),
|
|
17
|
+
'app/Models/Note.php': noteModel(),
|
|
18
|
+
'app/Http/Controllers/Api/AuthController.php': authController(p),
|
|
19
|
+
[`app/Http/Controllers/Api/${p.workspace.Model}Controller.php`]: workspaceController(p),
|
|
20
|
+
'app/Http/Controllers/Api/NoteController.php': noteController(),
|
|
21
|
+
'database/migrations/2026_01_01_100000_add_okjt_columns_to_users.php': usersMigration(),
|
|
22
|
+
[`database/migrations/2026_01_01_100001_create_${p.workspace.table}_table.php`]: workspaceMigration(p),
|
|
23
|
+
'database/migrations/2026_01_01_100002_create_notes_table.php': notesMigration(p),
|
|
24
|
+
'database/migrations/2026_01_01_100003_create_notifications_table.php': notificationsMigration(p),
|
|
25
|
+
'database/seeders/DatabaseSeeder.php': seeder(p),
|
|
26
|
+
'config/admin.php': adminConfig(p),
|
|
27
|
+
'tests/Feature/StarterTest.php': starterTest(p),
|
|
28
|
+
};
|
|
29
|
+
if (p.accounts) files['app/Models/Account.php'] = accountModel(p);
|
|
30
|
+
return files;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Project settings over the kit's (its file is the full, commented list). */
|
|
34
|
+
function okjtConfig(p) {
|
|
35
|
+
const w = p.workspace;
|
|
36
|
+
const open = [
|
|
37
|
+
'api/login', 'api/logout', 'api/register', 'api/user', 'api/verify-2fa', 'api/email/*', 'api/2fa/*',
|
|
38
|
+
'api/collaborators/accept', `api/${w.path}`,
|
|
39
|
+
p.features.google && 'api/auth/google',
|
|
40
|
+
p.accounts && 'api/team', p.accounts && 'api/team/*',
|
|
41
|
+
p.features.billing && 'api/plans', p.features.billing && 'api/billing', p.features.billing && 'api/billing/*', p.features.billing && 'api/paystack/webhook',
|
|
42
|
+
p.features.console && 'api/console/*',
|
|
43
|
+
p.features.backups && 'api/backups', p.features.backups && 'api/backups/*',
|
|
44
|
+
].filter(Boolean);
|
|
45
|
+
const roles = p.roles.map((r) => r.value);
|
|
46
|
+
|
|
47
|
+
return lines(
|
|
48
|
+
'<?php',
|
|
49
|
+
'',
|
|
50
|
+
'/*',
|
|
51
|
+
` * ${p.name}'s settings for the OKJT kit (okjt/laravel-kit). Only what differs`,
|
|
52
|
+
' * from the kit is here; everything else, and what each setting means, is in',
|
|
53
|
+
' * vendor/okjt/laravel-kit/config/okjt.php. Lists are replaced whole.',
|
|
54
|
+
' */',
|
|
55
|
+
"$kit = require base_path('vendor/okjt/laravel-kit/config/okjt.php');",
|
|
56
|
+
'',
|
|
57
|
+
'$config = array_replace_recursive($kit, [',
|
|
58
|
+
" 'workspace' => [",
|
|
59
|
+
` 'model' => App\\Models\\${w.Model}::class,`,
|
|
60
|
+
` 'foreign_key' => ${q(w.foreignKey)},`,
|
|
61
|
+
` 'members_table' => ${q(w.members)},`,
|
|
62
|
+
` 'folder' => ${q(w.table)},`,
|
|
63
|
+
' ],',
|
|
64
|
+
` 'label' => ${q(w.label)},`,
|
|
65
|
+
" 'auth' => [",
|
|
66
|
+
` 'token_name' => ${q(p.slug)},`,
|
|
67
|
+
` 'welcome' => ${q(`Welcome to ${p.name} — check your inbox to verify your email.`)},`,
|
|
68
|
+
' ],',
|
|
69
|
+
" 'collaboration' => [",
|
|
70
|
+
` 'token_name' => ${q(p.slug)},`,
|
|
71
|
+
` 'default_role' => ${q(p.preset === 'simple' ? 'viewer' : 'editor')},`,
|
|
72
|
+
" 'badge_modules' => ['notes'],",
|
|
73
|
+
' ],',
|
|
74
|
+
p.accounts && lines(
|
|
75
|
+
" 'accounts' => [",
|
|
76
|
+
" 'model' => App\\Models\\Account::class,",
|
|
77
|
+
` 'foreign_key' => 'account_id',`,
|
|
78
|
+
' ],',
|
|
79
|
+
),
|
|
80
|
+
p.features.billing && lines(
|
|
81
|
+
" 'billing' => [",
|
|
82
|
+
` 'reference_prefix' => ${q(`${p.slug.replace(/-/g, '_')}_`)},`,
|
|
83
|
+
` 'limit_names' => ['workspaces' => ${q(w.plural)}, 'collaborators' => 'people'],`,
|
|
84
|
+
' ],',
|
|
85
|
+
),
|
|
86
|
+
']);',
|
|
87
|
+
'',
|
|
88
|
+
`$config['workspace']['open_routes'] = [`,
|
|
89
|
+
...open.map((route) => ` ${q(route)},`),
|
|
90
|
+
'];',
|
|
91
|
+
`$config['roles']['all'] = [${roles.map(q).join(', ')}];`,
|
|
92
|
+
"$config['roles']['viewer_writes'] = ['api/logout', 'api/collaboration/heartbeat', 'api/2fa/*', 'api/notifications/mark-read'];",
|
|
93
|
+
p.features.billing && lines(
|
|
94
|
+
'',
|
|
95
|
+
'// Placeholder plans: set real prices before switching BILLING_ENABLED on.',
|
|
96
|
+
"$config['billing']['trial_limits'] = ['workspaces' => 3, 'collaborators' => 3];",
|
|
97
|
+
"$config['billing']['plans'] = [",
|
|
98
|
+
" 'free' => ['name' => 'Free', 'for' => [], 'price' => null, 'period' => null, 'limits' => ['workspaces' => 1, 'collaborators' => 3]],",
|
|
99
|
+
" 'pro' => ['name' => 'Pro', 'for' => [], 'price' => 2500, 'period' => 1, 'limits' => ['workspaces' => 20, 'collaborators' => 20]],",
|
|
100
|
+
'];',
|
|
101
|
+
),
|
|
102
|
+
'',
|
|
103
|
+
'return $config;',
|
|
104
|
+
'',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function bootstrap() {
|
|
109
|
+
return `<?php
|
|
110
|
+
|
|
111
|
+
use Illuminate\\Auth\\AuthenticationException;
|
|
112
|
+
use Illuminate\\Foundation\\Application;
|
|
113
|
+
use Illuminate\\Foundation\\Configuration\\Exceptions;
|
|
114
|
+
use Illuminate\\Foundation\\Configuration\\Middleware;
|
|
115
|
+
use Illuminate\\Http\\Request;
|
|
116
|
+
use Okjt\\Kit\\Http\\Middleware\\ResolveWorkspace;
|
|
117
|
+
|
|
118
|
+
return Application::configure(basePath: dirname(__DIR__))
|
|
119
|
+
->withRouting(
|
|
120
|
+
web: __DIR__.'/../routes/web.php',
|
|
121
|
+
api: __DIR__.'/../routes/api.php',
|
|
122
|
+
commands: __DIR__.'/../routes/console.php',
|
|
123
|
+
health: '/up',
|
|
124
|
+
)
|
|
125
|
+
->withMiddleware(function (Middleware $middleware): void {
|
|
126
|
+
// Before route-model binding, so bound records are already scoped to the workspace.
|
|
127
|
+
$middleware->api(prepend: [ResolveWorkspace::class]);
|
|
128
|
+
})
|
|
129
|
+
->withExceptions(function (Exceptions $exceptions): void {
|
|
130
|
+
$exceptions->render(function (AuthenticationException $e, Request $request) {
|
|
131
|
+
if ($request->is('api/*') || $request->expectsJson()) {
|
|
132
|
+
return response()->json(['message' => 'Unauthenticated.'], 401);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
})->create();
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function apiRoutes(p) {
|
|
140
|
+
const w = p.workspace;
|
|
141
|
+
const use = [
|
|
142
|
+
`App\\Http\\Controllers\\Api\\AuthController`,
|
|
143
|
+
`App\\Http\\Controllers\\Api\\NoteController`,
|
|
144
|
+
`App\\Http\\Controllers\\Api\\${w.Model}Controller`,
|
|
145
|
+
'Illuminate\\Support\\Facades\\Route',
|
|
146
|
+
p.features.backups && 'Okjt\\Kit\\Http\\Controllers\\BackupController',
|
|
147
|
+
p.features.billing && 'Okjt\\Kit\\Http\\Controllers\\BillingController',
|
|
148
|
+
'Okjt\\Kit\\Http\\Controllers\\CollaborationController',
|
|
149
|
+
p.features.console && 'Okjt\\Kit\\Http\\Controllers\\ConsoleController',
|
|
150
|
+
'Okjt\\Kit\\Http\\Controllers\\EmailVerificationController',
|
|
151
|
+
'Okjt\\Kit\\Http\\Controllers\\NotificationController',
|
|
152
|
+
'Okjt\\Kit\\Http\\Controllers\\RevisionController',
|
|
153
|
+
p.features.google && 'Okjt\\Kit\\Http\\Controllers\\SocialAuthController',
|
|
154
|
+
p.accounts && 'Okjt\\Kit\\Http\\Controllers\\TeamController',
|
|
155
|
+
'Okjt\\Kit\\Http\\Controllers\\TwoFactorController',
|
|
156
|
+
'Okjt\\Kit\\Http\\Middleware\\EnforceRole',
|
|
157
|
+
p.platform && (p.features.console || p.features.backups) && 'Okjt\\Kit\\Http\\Middleware\\EnsureSuperadmin',
|
|
158
|
+
].filter(Boolean).sort((a, b) => a.localeCompare(b));
|
|
159
|
+
|
|
160
|
+
return lines(
|
|
161
|
+
'<?php',
|
|
162
|
+
'',
|
|
163
|
+
...use.map((c) => `use ${c};`),
|
|
164
|
+
'',
|
|
165
|
+
'/*',
|
|
166
|
+
' * Without a workspace (see open_routes in config/okjt.php): signing in and',
|
|
167
|
+
` * up, your account, your ${w.plural}${p.accounts ? ', your team' : ''}${p.features.billing ? ', billing' : ''}${p.features.console ? ', the console' : ''}.`,
|
|
168
|
+
` * Everything else works on the ${w.label} the request names (X-Workspace).`,
|
|
169
|
+
' */',
|
|
170
|
+
"Route::middleware('throttle:15,1')->group(function () {",
|
|
171
|
+
" Route::post('/login', [AuthController::class, 'login']);",
|
|
172
|
+
" Route::post('/verify-2fa', [AuthController::class, 'verify2fa']);",
|
|
173
|
+
" Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:5,1,register');",
|
|
174
|
+
" Route::post('/email/verify', [EmailVerificationController::class, 'verify']);",
|
|
175
|
+
" Route::post('/email/resend', [EmailVerificationController::class, 'resend']);",
|
|
176
|
+
" Route::post('/collaborators/accept', [CollaborationController::class, 'accept']);",
|
|
177
|
+
p.features.google && " Route::post('/auth/google', [SocialAuthController::class, 'login']);",
|
|
178
|
+
'});',
|
|
179
|
+
p.features.billing && lines(
|
|
180
|
+
'',
|
|
181
|
+
"Route::get('/plans', [BillingController::class, 'plans']);",
|
|
182
|
+
"Route::post('/paystack/webhook', [BillingController::class, 'webhook']);",
|
|
183
|
+
),
|
|
184
|
+
'',
|
|
185
|
+
"Route::middleware(['auth:sanctum', EnforceRole::class])->group(function () {",
|
|
186
|
+
" Route::post('/logout', [AuthController::class, 'logout']);",
|
|
187
|
+
" Route::get('/user', [AuthController::class, 'user']);",
|
|
188
|
+
" Route::post('/2fa/setup', [TwoFactorController::class, 'setup']);",
|
|
189
|
+
" Route::post('/2fa/confirm', [TwoFactorController::class, 'confirm']);",
|
|
190
|
+
" Route::post('/2fa/disable', [TwoFactorController::class, 'disable']);",
|
|
191
|
+
'',
|
|
192
|
+
` Route::get('/${w.path}', [${w.Model}Controller::class, 'index']);`,
|
|
193
|
+
` Route::post('/${w.path}', [${w.Model}Controller::class, 'store'])->middleware('throttle:10,1,new-workspace');`,
|
|
194
|
+
p.accounts && lines(
|
|
195
|
+
'',
|
|
196
|
+
" Route::get('/team', [TeamController::class, 'index']);",
|
|
197
|
+
" Route::post('/team', [TeamController::class, 'invite'])->middleware('throttle:20,1,team');",
|
|
198
|
+
" Route::put('/team/{user}', [TeamController::class, 'update']);",
|
|
199
|
+
" Route::delete('/team/{user}', [TeamController::class, 'destroy']);",
|
|
200
|
+
),
|
|
201
|
+
p.features.billing && lines(
|
|
202
|
+
'',
|
|
203
|
+
" Route::get('/billing', [BillingController::class, 'show']);",
|
|
204
|
+
" Route::post('/billing/checkout', [BillingController::class, 'checkout'])->middleware('throttle:10,1,checkout');",
|
|
205
|
+
" Route::get('/billing/verify', [BillingController::class, 'verify']);",
|
|
206
|
+
),
|
|
207
|
+
'',
|
|
208
|
+
` // Inside a ${w.label}: working together, version history, the bell, and its notes.`,
|
|
209
|
+
" Route::post('/collaboration/heartbeat', [CollaborationController::class, 'heartbeat']);",
|
|
210
|
+
" Route::get('/collaborators', [CollaborationController::class, 'collaborators']);",
|
|
211
|
+
" Route::middleware(EnforceRole::class.':owner')->group(function () {",
|
|
212
|
+
" Route::post('/collaborators/invite', [CollaborationController::class, 'invite']);",
|
|
213
|
+
" Route::patch('/collaborators/{user}/role', [CollaborationController::class, 'updateRole']);",
|
|
214
|
+
" Route::delete('/collaborators/{user}', [CollaborationController::class, 'remove']);",
|
|
215
|
+
' });',
|
|
216
|
+
" Route::get('/revisions', [RevisionController::class, 'index']);",
|
|
217
|
+
" Route::post('/revisions/{revision}/restore', [RevisionController::class, 'restore']);",
|
|
218
|
+
" Route::get('/notifications', [NotificationController::class, 'index']);",
|
|
219
|
+
" Route::post('/notifications/mark-read', [NotificationController::class, 'markAllRead']);",
|
|
220
|
+
'',
|
|
221
|
+
" Route::get('/notes', [NoteController::class, 'index']);",
|
|
222
|
+
" Route::post('/notes', [NoteController::class, 'store']);",
|
|
223
|
+
" Route::put('/notes/{note}', [NoteController::class, 'update']);",
|
|
224
|
+
" Route::delete('/notes/{note}', [NoteController::class, 'destroy']);",
|
|
225
|
+
p.features.console && lines(
|
|
226
|
+
'',
|
|
227
|
+
" Route::prefix('console')->middleware(EnsureSuperadmin::class)->group(function () {",
|
|
228
|
+
" Route::get('/workspaces', [ConsoleController::class, 'workspaces']);",
|
|
229
|
+
" Route::patch('/workspaces/{workspace}/status', [ConsoleController::class, 'setStatus']);",
|
|
230
|
+
" Route::delete('/workspaces/{workspace}', [ConsoleController::class, 'destroyWorkspace']);",
|
|
231
|
+
" Route::get('/audit', [ConsoleController::class, 'audit']);",
|
|
232
|
+
' });',
|
|
233
|
+
),
|
|
234
|
+
p.features.backups && lines(
|
|
235
|
+
'',
|
|
236
|
+
" Route::prefix('backups')->middleware(EnsureSuperadmin::class)->group(function () {",
|
|
237
|
+
" Route::get('/', [BackupController::class, 'index']);",
|
|
238
|
+
" Route::post('/', [BackupController::class, 'store']);",
|
|
239
|
+
" Route::get('/{name}/download', [BackupController::class, 'download']);",
|
|
240
|
+
" Route::post('/{name}/restore', [BackupController::class, 'restore']);",
|
|
241
|
+
" Route::post('/{name}/verify', [BackupController::class, 'verify']);",
|
|
242
|
+
" Route::delete('/{name}', [BackupController::class, 'destroy']);",
|
|
243
|
+
' });',
|
|
244
|
+
),
|
|
245
|
+
'});',
|
|
246
|
+
'',
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function webRoutes(p) {
|
|
251
|
+
const web = p.features.google || p.features.passkeys;
|
|
252
|
+
return lines(
|
|
253
|
+
'<?php',
|
|
254
|
+
'',
|
|
255
|
+
p.features.passkeys && 'use Okjt\\Kit\\Http\\Controllers\\PasskeyController;',
|
|
256
|
+
p.features.google && 'use Okjt\\Kit\\Http\\Controllers\\SocialAuthController;',
|
|
257
|
+
'use Illuminate\\Support\\Facades\\Route;',
|
|
258
|
+
'',
|
|
259
|
+
web && '// Passkey challenges and Google\'s OAuth state ride the session, so these are web routes.',
|
|
260
|
+
p.features.passkeys && lines(
|
|
261
|
+
"Route::prefix('api')->middleware(['web', 'throttle:6,1'])->group(function () {",
|
|
262
|
+
" Route::get('/passkeys/login/options', [PasskeyController::class, 'loginOptions']);",
|
|
263
|
+
" Route::post('/passkeys/login', [PasskeyController::class, 'login']);",
|
|
264
|
+
" Route::get('/passkeys/options', [PasskeyController::class, 'registerOptions'])->middleware('auth:sanctum');",
|
|
265
|
+
" Route::get('/passkeys', [PasskeyController::class, 'index'])->middleware('auth:sanctum');",
|
|
266
|
+
" Route::post('/passkeys', [PasskeyController::class, 'register'])->middleware('auth:sanctum');",
|
|
267
|
+
" Route::delete('/passkeys/{id}', [PasskeyController::class, 'destroy'])->middleware('auth:sanctum');",
|
|
268
|
+
'});',
|
|
269
|
+
'',
|
|
270
|
+
),
|
|
271
|
+
p.features.google && lines(
|
|
272
|
+
"Route::prefix('api')->middleware(['web', 'throttle:20,1'])->group(function () {",
|
|
273
|
+
" Route::get('/auth/google', [SocialAuthController::class, 'redirect']);",
|
|
274
|
+
" Route::get('/auth/google/callback', [SocialAuthController::class, 'callback']);",
|
|
275
|
+
'});',
|
|
276
|
+
'',
|
|
277
|
+
),
|
|
278
|
+
'// The React app (built into public/ when deployed) answers every other page.',
|
|
279
|
+
"Route::get('/{any?}', function () {",
|
|
280
|
+
" $index = public_path('index.html');",
|
|
281
|
+
'',
|
|
282
|
+
" return file_exists($index) ? response()->file($index) : response('Run the frontend: cd frontend && npm run dev', 200);",
|
|
283
|
+
"})->where('any', '^(?!api|up|storage|assets).*$');",
|
|
284
|
+
'',
|
|
285
|
+
'// Where Laravel sends people it can\'t authenticate (the API answers 401 instead).',
|
|
286
|
+
"Route::get('/login', fn () => response()->json(['message' => 'Unauthenticated.'], 401))->name('login');",
|
|
287
|
+
'',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function userModel(p) {
|
|
292
|
+
const passkeys = p.features.passkeys;
|
|
293
|
+
return lines(
|
|
294
|
+
'<?php',
|
|
295
|
+
'',
|
|
296
|
+
'namespace App\\Models;',
|
|
297
|
+
'',
|
|
298
|
+
'use Illuminate\\Contracts\\Auth\\MustVerifyEmail;',
|
|
299
|
+
'use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;',
|
|
300
|
+
'use Illuminate\\Foundation\\Auth\\User as Authenticatable;',
|
|
301
|
+
'use Illuminate\\Notifications\\Notifiable;',
|
|
302
|
+
passkeys && 'use Laravel\\Passkeys\\Contracts\\PasskeyUser;',
|
|
303
|
+
passkeys && 'use Laravel\\Passkeys\\PasskeyAuthenticatable;',
|
|
304
|
+
'use Laravel\\Sanctum\\HasApiTokens;',
|
|
305
|
+
p.accounts && 'use Okjt\\Kit\\Accounts\\HasAccounts;',
|
|
306
|
+
'use Okjt\\Kit\\Auth\\VerifyEmail;',
|
|
307
|
+
'use Okjt\\Kit\\Workspace\\HasWorkspaceRoles;',
|
|
308
|
+
'',
|
|
309
|
+
'/**',
|
|
310
|
+
` * Someone who signs in to ${p.name}. Their role is per ${p.workspace.label}`,
|
|
311
|
+
` * (HasWorkspaceRoles: \`role\` is their role in the current one)${p.accounts ? ', and they own or work for accounts (HasAccounts)' : ''}.`,
|
|
312
|
+
p.platform && ' * `is_superadmin` marks the people who run the platform.',
|
|
313
|
+
' */',
|
|
314
|
+
`class User extends Authenticatable implements MustVerifyEmail${passkeys ? ', PasskeyUser' : ''}`,
|
|
315
|
+
'{',
|
|
316
|
+
` use ${[p.accounts && 'HasAccounts', 'HasApiTokens', 'HasFactory', 'HasWorkspaceRoles', 'Notifiable', passkeys && 'PasskeyAuthenticatable'].filter(Boolean).join(', ')};`,
|
|
317
|
+
'',
|
|
318
|
+
" protected $fillable = ['name', 'email', 'password', 'role'];",
|
|
319
|
+
'',
|
|
320
|
+
" protected $hidden = ['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'];",
|
|
321
|
+
'',
|
|
322
|
+
' protected function casts(): array',
|
|
323
|
+
' {',
|
|
324
|
+
' return [',
|
|
325
|
+
" 'email_verified_at' => 'datetime',",
|
|
326
|
+
" 'password' => 'hashed',",
|
|
327
|
+
" 'is_superadmin' => 'boolean',",
|
|
328
|
+
" 'two_factor_confirmed_at' => 'datetime',",
|
|
329
|
+
" 'last_active_at' => 'datetime',",
|
|
330
|
+
" 'invite_accepted_at' => 'datetime',",
|
|
331
|
+
' ];',
|
|
332
|
+
' }',
|
|
333
|
+
'',
|
|
334
|
+
' /** The kit\'s verification email: a signed link to the app\'s /verify-email page. */',
|
|
335
|
+
' public function sendEmailVerificationNotification(): void',
|
|
336
|
+
' {',
|
|
337
|
+
' $this->notify(new VerifyEmail);',
|
|
338
|
+
' }',
|
|
339
|
+
'}',
|
|
340
|
+
'',
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function workspaceModel(p) {
|
|
345
|
+
const w = p.workspace;
|
|
346
|
+
return lines(
|
|
347
|
+
'<?php',
|
|
348
|
+
'',
|
|
349
|
+
'namespace App\\Models;',
|
|
350
|
+
'',
|
|
351
|
+
'use Illuminate\\Database\\Eloquent\\Model;',
|
|
352
|
+
p.accounts && 'use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;',
|
|
353
|
+
'use Okjt\\Kit\\Workspace\\Workspace;',
|
|
354
|
+
'',
|
|
355
|
+
`/** A ${w.label}: what people work on together (the kit's workspace). Requests name it by its slug. */`,
|
|
356
|
+
`class ${w.Model} extends Model implements Workspace`,
|
|
357
|
+
'{',
|
|
358
|
+
` protected $fillable = ['name', 'slug', 'status'${p.accounts ? ", 'account_id'" : ''}];`,
|
|
359
|
+
'',
|
|
360
|
+
' public static function findByKey(?string $key): ?self',
|
|
361
|
+
' {',
|
|
362
|
+
" return $key ? static::where('slug', $key)->first() : null;",
|
|
363
|
+
' }',
|
|
364
|
+
'',
|
|
365
|
+
' public function isActive(): bool',
|
|
366
|
+
' {',
|
|
367
|
+
" return $this->status === 'active';",
|
|
368
|
+
' }',
|
|
369
|
+
'',
|
|
370
|
+
` /** ${w.plural} have no public pages, so there's nothing to publish. */`,
|
|
371
|
+
' public function isPublished(): bool',
|
|
372
|
+
' {',
|
|
373
|
+
' return true;',
|
|
374
|
+
' }',
|
|
375
|
+
p.accounts && lines(
|
|
376
|
+
'',
|
|
377
|
+
' public function account(): BelongsTo',
|
|
378
|
+
' {',
|
|
379
|
+
' return $this->belongsTo(Account::class);',
|
|
380
|
+
' }',
|
|
381
|
+
),
|
|
382
|
+
'}',
|
|
383
|
+
'',
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function accountModel(p) {
|
|
388
|
+
return lines(
|
|
389
|
+
'<?php',
|
|
390
|
+
'',
|
|
391
|
+
'namespace App\\Models;',
|
|
392
|
+
'',
|
|
393
|
+
'use Illuminate\\Database\\Eloquent\\Model;',
|
|
394
|
+
'use Okjt\\Kit\\Accounts\\HasTeam;',
|
|
395
|
+
'',
|
|
396
|
+
`/** A customer: owns ${p.workspace.plural}; its people are owners or staff (HasTeam)${p.features.billing ? ', and it has a plan' : ''}. */`,
|
|
397
|
+
'class Account extends Model',
|
|
398
|
+
'{',
|
|
399
|
+
' use HasTeam;',
|
|
400
|
+
'',
|
|
401
|
+
` protected $fillable = ['name'${p.features.billing ? ", 'plan', 'plan_expires_at'" : ''}];`,
|
|
402
|
+
p.features.billing && lines(
|
|
403
|
+
'',
|
|
404
|
+
' protected function casts(): array',
|
|
405
|
+
' {',
|
|
406
|
+
" return ['plan_expires_at' => 'datetime'];",
|
|
407
|
+
' }',
|
|
408
|
+
),
|
|
409
|
+
'}',
|
|
410
|
+
'',
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function noteModel() {
|
|
415
|
+
return `<?php
|
|
416
|
+
|
|
417
|
+
namespace App\\Models;
|
|
418
|
+
|
|
419
|
+
use Illuminate\\Database\\Eloquent\\Model;
|
|
420
|
+
use Okjt\\Kit\\Revisions\\RecordsRevisions;
|
|
421
|
+
use Okjt\\Kit\\Workspace\\BelongsToWorkspace;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The starter module: notes in a workspace. Copy it for your own: scoped to
|
|
425
|
+
* the current workspace (BelongsToWorkspace), every change kept in version
|
|
426
|
+
* history (RecordsRevisions), under the dashboard module "notes".
|
|
427
|
+
*/
|
|
428
|
+
class Note extends Model
|
|
429
|
+
{
|
|
430
|
+
use BelongsToWorkspace, RecordsRevisions;
|
|
431
|
+
|
|
432
|
+
protected string $revisionModule = 'notes';
|
|
433
|
+
|
|
434
|
+
protected $fillable = ['title', 'body'];
|
|
435
|
+
}
|
|
436
|
+
`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function authController(p) {
|
|
440
|
+
return lines(
|
|
441
|
+
'<?php',
|
|
442
|
+
'',
|
|
443
|
+
'namespace App\\Http\\Controllers\\Api;',
|
|
444
|
+
'',
|
|
445
|
+
p.accounts && 'use App\\Models\\Account;',
|
|
446
|
+
p.accounts && 'use Illuminate\\Database\\Eloquent\\Model;',
|
|
447
|
+
'use Okjt\\Kit\\Http\\Controllers\\AuthController as KitAuthController;',
|
|
448
|
+
'',
|
|
449
|
+
'/**',
|
|
450
|
+
' * Signing in, two-step codes, signing up, signing out and who you are: the',
|
|
451
|
+
` * OKJT kit's.${p.accounts ? ' Signing up also makes your account, which you own.' : ''}`,
|
|
452
|
+
' */',
|
|
453
|
+
'class AuthController extends KitAuthController',
|
|
454
|
+
'{',
|
|
455
|
+
p.accounts && lines(
|
|
456
|
+
' protected function registered(Model $user, array $validated): array',
|
|
457
|
+
' {',
|
|
458
|
+
" $account = Account::create(['name' => $user->name]);",
|
|
459
|
+
" $account->members()->attach($user->id, ['role' => config('okjt.accounts.owner_role')]);",
|
|
460
|
+
'',
|
|
461
|
+
" return ['account' => ['id' => $account->id, 'name' => $account->name]];",
|
|
462
|
+
' }',
|
|
463
|
+
),
|
|
464
|
+
'}',
|
|
465
|
+
'',
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function workspaceController(p) {
|
|
470
|
+
const w = p.workspace;
|
|
471
|
+
return lines(
|
|
472
|
+
'<?php',
|
|
473
|
+
'',
|
|
474
|
+
'namespace App\\Http\\Controllers\\Api;',
|
|
475
|
+
'',
|
|
476
|
+
`use App\\Models\\${w.Model};`,
|
|
477
|
+
'use Illuminate\\Http\\JsonResponse;',
|
|
478
|
+
'use Illuminate\\Http\\Request;',
|
|
479
|
+
'use Illuminate\\Routing\\Controller;',
|
|
480
|
+
'use Illuminate\\Support\\Facades\\DB;',
|
|
481
|
+
'use Illuminate\\Support\\Str;',
|
|
482
|
+
p.features.billing && 'use Okjt\\Kit\\Billing\\Limits;',
|
|
483
|
+
'use Okjt\\Kit\\Support\\ApiResponse;',
|
|
484
|
+
'',
|
|
485
|
+
`/** Your ${w.plural}: the ones you can open (with your role), and starting a new one. */`,
|
|
486
|
+
`class ${w.Model}Controller extends Controller`,
|
|
487
|
+
'{',
|
|
488
|
+
' use ApiResponse;',
|
|
489
|
+
'',
|
|
490
|
+
' public function index(Request $request): JsonResponse',
|
|
491
|
+
' {',
|
|
492
|
+
` $rows = DB::table(${q(w.members)})`,
|
|
493
|
+
` ->join(${q(w.table)}, ${q(`${w.table}.id`)}, '=', ${q(`${w.members}.${w.foreignKey}`)})`,
|
|
494
|
+
` ->where(${q(`${w.members}.user_id`)}, $request->user()->id)`,
|
|
495
|
+
` ->orderBy(${q(`${w.table}.name`)})`,
|
|
496
|
+
` ->get([${q(`${w.table}.id`)}, ${q(`${w.table}.name`)}, ${q(`${w.table}.slug`)}, ${q(`${w.table}.status`)}, ${q(`${w.members}.role`)}]);`,
|
|
497
|
+
'',
|
|
498
|
+
' return $this->successResponse($rows);',
|
|
499
|
+
' }',
|
|
500
|
+
'',
|
|
501
|
+
' public function store(Request $request): JsonResponse',
|
|
502
|
+
' {',
|
|
503
|
+
" $validated = $request->validate(['name' => 'required|string|max:120']);",
|
|
504
|
+
p.accounts && lines(
|
|
505
|
+
' $account = $request->user()->ownedAccount();',
|
|
506
|
+
' if (! $account) {',
|
|
507
|
+
` return $this->forbiddenResponse(${q(`Only an account's owners start new ${w.plural}.`)});`,
|
|
508
|
+
' }',
|
|
509
|
+
p.features.billing && " Limits::ensure('workspaces', 1, $account);",
|
|
510
|
+
),
|
|
511
|
+
'',
|
|
512
|
+
" $slug = Str::slug($validated['name']) ?: Str::lower(Str::random(8));",
|
|
513
|
+
` while (${w.Model}::where('slug', $slug)->exists()) {`,
|
|
514
|
+
" $slug = Str::slug($validated['name']).'-'.Str::lower(Str::random(4));",
|
|
515
|
+
' }',
|
|
516
|
+
'',
|
|
517
|
+
` $workspace = ${w.Model}::create(['name' => $validated['name'], 'slug' => $slug, 'status' => 'active'${p.accounts ? ", 'account_id' => $account->id" : ''}]);`,
|
|
518
|
+
" $request->user()->setRoleIn($workspace, config('okjt.roles.owner'));",
|
|
519
|
+
p.accounts && ' $account->shareWithTeam($workspace);',
|
|
520
|
+
'',
|
|
521
|
+
" return $this->createdResponse($workspace->only(['id', 'name', 'slug', 'status']) + ['role' => config('okjt.roles.owner')]);",
|
|
522
|
+
' }',
|
|
523
|
+
'}',
|
|
524
|
+
'',
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function noteController() {
|
|
529
|
+
return `<?php
|
|
530
|
+
|
|
531
|
+
namespace App\\Http\\Controllers\\Api;
|
|
532
|
+
|
|
533
|
+
use App\\Models\\Note;
|
|
534
|
+
use Illuminate\\Http\\JsonResponse;
|
|
535
|
+
use Illuminate\\Http\\Request;
|
|
536
|
+
use Illuminate\\Routing\\Controller;
|
|
537
|
+
use Okjt\\Kit\\Support\\ApiResponse;
|
|
538
|
+
|
|
539
|
+
/** The starter module's API: the current workspace's notes (viewers may only read; EnforceRole). */
|
|
540
|
+
class NoteController extends Controller
|
|
541
|
+
{
|
|
542
|
+
use ApiResponse;
|
|
543
|
+
|
|
544
|
+
public function index(): JsonResponse
|
|
545
|
+
{
|
|
546
|
+
return $this->successResponse(Note::latest('updated_at')->get());
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
public function store(Request $request): JsonResponse
|
|
550
|
+
{
|
|
551
|
+
return $this->createdResponse(Note::create($this->validated($request)), 'Note added');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
public function update(Request $request, Note $note): JsonResponse
|
|
555
|
+
{
|
|
556
|
+
$note->update($this->validated($request));
|
|
557
|
+
|
|
558
|
+
return $this->successResponse($note, 'Note saved');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
public function destroy(Note $note): JsonResponse
|
|
562
|
+
{
|
|
563
|
+
$note->delete();
|
|
564
|
+
|
|
565
|
+
return $this->deletedResponse('Note deleted');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private function validated(Request $request): array
|
|
569
|
+
{
|
|
570
|
+
return $request->validate(['title' => 'required|string|max:160', 'body' => 'nullable|string|max:10000']);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function usersMigration() {
|
|
577
|
+
return `<?php
|
|
578
|
+
|
|
579
|
+
use Illuminate\\Database\\Migrations\\Migration;
|
|
580
|
+
use Illuminate\\Database\\Schema\\Blueprint;
|
|
581
|
+
use Illuminate\\Support\\Facades\\Schema;
|
|
582
|
+
|
|
583
|
+
/** What the kit reads on users beyond its own migrations: a role, and two-step verification. */
|
|
584
|
+
return new class extends Migration
|
|
585
|
+
{
|
|
586
|
+
public function up(): void
|
|
587
|
+
{
|
|
588
|
+
Schema::table('users', function (Blueprint $table) {
|
|
589
|
+
$table->string('role', 20)->nullable();
|
|
590
|
+
$table->text('two_factor_secret')->nullable();
|
|
591
|
+
$table->text('two_factor_recovery_codes')->nullable();
|
|
592
|
+
$table->timestamp('two_factor_confirmed_at')->nullable();
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
public function down(): void
|
|
597
|
+
{
|
|
598
|
+
Schema::table('users', fn (Blueprint $table) => $table->dropColumn(['role', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at']));
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function workspaceMigration(p) {
|
|
605
|
+
const w = p.workspace;
|
|
606
|
+
return lines(
|
|
607
|
+
'<?php',
|
|
608
|
+
'',
|
|
609
|
+
'use Illuminate\\Database\\Migrations\\Migration;',
|
|
610
|
+
'use Illuminate\\Database\\Schema\\Blueprint;',
|
|
611
|
+
'use Illuminate\\Support\\Facades\\Schema;',
|
|
612
|
+
'',
|
|
613
|
+
`/** ${w.plural}: what people work on together. */`,
|
|
614
|
+
'return new class extends Migration',
|
|
615
|
+
'{',
|
|
616
|
+
' public function up(): void',
|
|
617
|
+
' {',
|
|
618
|
+
` Schema::create(${q(w.table)}, function (Blueprint $table) {`,
|
|
619
|
+
' $table->id();',
|
|
620
|
+
p.accounts && " $table->unsignedBigInteger('account_id')->nullable()->index();",
|
|
621
|
+
" $table->string('name');",
|
|
622
|
+
" $table->string('slug', 80)->unique();",
|
|
623
|
+
" $table->string('status', 20)->default('active'); // active | suspended | archived",
|
|
624
|
+
' $table->timestamps();',
|
|
625
|
+
' });',
|
|
626
|
+
' }',
|
|
627
|
+
'',
|
|
628
|
+
' public function down(): void',
|
|
629
|
+
' {',
|
|
630
|
+
` Schema::dropIfExists(${q(w.table)});`,
|
|
631
|
+
' }',
|
|
632
|
+
'};',
|
|
633
|
+
'',
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function notesMigration(p) {
|
|
638
|
+
return lines(
|
|
639
|
+
'<?php',
|
|
640
|
+
'',
|
|
641
|
+
'use Illuminate\\Database\\Migrations\\Migration;',
|
|
642
|
+
'use Illuminate\\Database\\Schema\\Blueprint;',
|
|
643
|
+
'use Illuminate\\Support\\Facades\\Schema;',
|
|
644
|
+
'',
|
|
645
|
+
'/** The starter module: notes, each in one workspace. */',
|
|
646
|
+
'return new class extends Migration',
|
|
647
|
+
'{',
|
|
648
|
+
' public function up(): void',
|
|
649
|
+
' {',
|
|
650
|
+
" Schema::create('notes', function (Blueprint $table) {",
|
|
651
|
+
' $table->id();',
|
|
652
|
+
` $table->unsignedBigInteger(${q(p.workspace.foreignKey)})->index();`,
|
|
653
|
+
" $table->string('title');",
|
|
654
|
+
" $table->text('body')->nullable();",
|
|
655
|
+
' $table->timestamps();',
|
|
656
|
+
' });',
|
|
657
|
+
' }',
|
|
658
|
+
'',
|
|
659
|
+
' public function down(): void',
|
|
660
|
+
' {',
|
|
661
|
+
" Schema::dropIfExists('notes');",
|
|
662
|
+
' }',
|
|
663
|
+
'};',
|
|
664
|
+
'',
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function seeder(p) {
|
|
669
|
+
return lines(
|
|
670
|
+
'<?php',
|
|
671
|
+
'',
|
|
672
|
+
'namespace Database\\Seeders;',
|
|
673
|
+
'',
|
|
674
|
+
p.accounts && 'use App\\Models\\Account;',
|
|
675
|
+
'use App\\Models\\User;',
|
|
676
|
+
'use Illuminate\\Database\\Seeder;',
|
|
677
|
+
'',
|
|
678
|
+
'/**',
|
|
679
|
+
` * The first person who can sign in: ${p.platform ? 'a superadmin' : 'an admin'}, verified, from`,
|
|
680
|
+
' * OKJT_ADMIN_EMAIL / OKJT_ADMIN_PASSWORD in .env (config/admin.php). Run once: php artisan db:seed',
|
|
681
|
+
' */',
|
|
682
|
+
'class DatabaseSeeder extends Seeder',
|
|
683
|
+
'{',
|
|
684
|
+
' public function run(): void',
|
|
685
|
+
' {',
|
|
686
|
+
" $email = config('admin.email');",
|
|
687
|
+
" $password = config('admin.password');",
|
|
688
|
+
' if (! $email || ! $password) {',
|
|
689
|
+
" $this->command?->warn('Set OKJT_ADMIN_EMAIL and OKJT_ADMIN_PASSWORD in .env to create the first user.');",
|
|
690
|
+
'',
|
|
691
|
+
' return;',
|
|
692
|
+
' }',
|
|
693
|
+
'',
|
|
694
|
+
' $user = User::firstOrNew([\'email\' => $email]);',
|
|
695
|
+
" $user->forceFill([",
|
|
696
|
+
" 'name' => config('admin.name'),",
|
|
697
|
+
" 'password' => $password,",
|
|
698
|
+
" 'role' => config('okjt.roles.owner'),",
|
|
699
|
+
" 'email_verified_at' => now(),",
|
|
700
|
+
p.platform && " 'is_superadmin' => true,",
|
|
701
|
+
' ])->save();',
|
|
702
|
+
p.accounts && lines(
|
|
703
|
+
'',
|
|
704
|
+
' if (! $user->ownedAccount()) {',
|
|
705
|
+
" Account::create(['name' => $user->name])->members()->attach($user->id, ['role' => config('okjt.accounts.owner_role')]);",
|
|
706
|
+
' }',
|
|
707
|
+
),
|
|
708
|
+
' }',
|
|
709
|
+
'}',
|
|
710
|
+
'',
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function starterTest(p) {
|
|
715
|
+
const w = p.workspace;
|
|
716
|
+
return lines(
|
|
717
|
+
'<?php',
|
|
718
|
+
'',
|
|
719
|
+
'namespace Tests\\Feature;',
|
|
720
|
+
'',
|
|
721
|
+
p.accounts && 'use App\\Models\\Account;',
|
|
722
|
+
'use App\\Models\\User;',
|
|
723
|
+
'use Illuminate\\Foundation\\Testing\\RefreshDatabase;',
|
|
724
|
+
'use Tests\\TestCase;',
|
|
725
|
+
'',
|
|
726
|
+
`/** The starter works end to end: signing up and in, a ${w.label}, its notes and their history, and roles. */`,
|
|
727
|
+
'class StarterTest extends TestCase',
|
|
728
|
+
'{',
|
|
729
|
+
' use RefreshDatabase;',
|
|
730
|
+
'',
|
|
731
|
+
' private function person(string $email): User',
|
|
732
|
+
' {',
|
|
733
|
+
" $user = User::create(['name' => ucfirst(strtok($email, '@')), 'email' => $email, 'password' => 'secret-password']);",
|
|
734
|
+
" $user->forceFill(['email_verified_at' => now()])->save();",
|
|
735
|
+
p.accounts && " Account::create(['name' => $user->name])->members()->attach($user->id, ['role' => 'owner']);",
|
|
736
|
+
'',
|
|
737
|
+
' return $user;',
|
|
738
|
+
' }',
|
|
739
|
+
'',
|
|
740
|
+
' public function test_signing_up_asks_for_email_verification_first(): void',
|
|
741
|
+
' {',
|
|
742
|
+
" $this->postJson('/api/register', ['name' => 'Ann', 'email' => 'ann@example.com', 'password' => 'secret-password', 'password_confirmation' => 'secret-password'])",
|
|
743
|
+
" ->assertCreated()->assertJsonPath('data.requires_verification', true);",
|
|
744
|
+
'',
|
|
745
|
+
" $this->postJson('/api/login', ['email' => 'ann@example.com', 'password' => 'secret-password'])",
|
|
746
|
+
" ->assertOk()->assertJsonPath('data.requires_verification', true);",
|
|
747
|
+
' }',
|
|
748
|
+
'',
|
|
749
|
+
' public function test_a_verified_person_signs_in(): void',
|
|
750
|
+
' {',
|
|
751
|
+
" $this->person('bo@example.com');",
|
|
752
|
+
'',
|
|
753
|
+
" $this->postJson('/api/login', ['email' => 'bo@example.com', 'password' => 'secret-password'])",
|
|
754
|
+
" ->assertOk()->assertJsonPath('data.user.email', 'bo@example.com')",
|
|
755
|
+
" ->assertJsonPath('data.token', fn ($token) => ! empty($token));",
|
|
756
|
+
' }',
|
|
757
|
+
'',
|
|
758
|
+
` public function test_a_${w.label.replace(/ /g, '_')}_has_notes_with_history_and_viewers_only_read(): void`,
|
|
759
|
+
' {',
|
|
760
|
+
" $owner = $this->person('owner@example.com');",
|
|
761
|
+
` $slug = $this->actingAs($owner, 'sanctum')->postJson('/api/${w.path}', ['name' => 'First one'])->assertCreated()->json('data.slug');`,
|
|
762
|
+
` $this->getJson('/api/${w.path}')->assertJsonPath('data.0.role', 'owner');`,
|
|
763
|
+
'',
|
|
764
|
+
" $headers = ['X-Workspace' => $slug];",
|
|
765
|
+
" $note = $this->postJson('/api/notes', ['title' => 'Kick-off'], $headers)->assertCreated()->json('data');",
|
|
766
|
+
" $this->putJson(\"/api/notes/{$note['id']}\", ['title' => 'Kick-off, Monday'], $headers)->assertOk();",
|
|
767
|
+
" $this->getJson('/api/revisions', $headers)->assertOk()->assertJsonPath('data.data.0.module', 'notes');",
|
|
768
|
+
'',
|
|
769
|
+
" $viewer = $this->person('viewer@example.com');",
|
|
770
|
+
` $viewer->setRoleIn(\\App\\Models\\${w.Model}::where('slug', $slug)->first(), 'viewer');`,
|
|
771
|
+
" $this->actingAs($viewer, 'sanctum')->getJson('/api/notes', $headers)->assertOk()->assertJsonCount(1, 'data');",
|
|
772
|
+
" $this->postJson('/api/notes', ['title' => 'Mine'], $headers)->assertForbidden();",
|
|
773
|
+
'',
|
|
774
|
+
" $stranger = $this->person('stranger@example.com');",
|
|
775
|
+
" $this->actingAs($stranger, 'sanctum')->getJson('/api/notes', $headers)->assertForbidden();",
|
|
776
|
+
' }',
|
|
777
|
+
'}',
|
|
778
|
+
'',
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function notificationsMigration(p) {
|
|
783
|
+
return lines(
|
|
784
|
+
'<?php',
|
|
785
|
+
'',
|
|
786
|
+
'use Illuminate\\Database\\Migrations\\Migration;',
|
|
787
|
+
'use Illuminate\\Database\\Schema\\Blueprint;',
|
|
788
|
+
'use Illuminate\\Support\\Facades\\Schema;',
|
|
789
|
+
'',
|
|
790
|
+
`/** The dashboard's bell: Laravel's notifications, each in one ${p.workspace.label} (the kit's Notification model). */`,
|
|
791
|
+
'return new class extends Migration',
|
|
792
|
+
'{',
|
|
793
|
+
' public function up(): void',
|
|
794
|
+
' {',
|
|
795
|
+
" Schema::create('notifications', function (Blueprint $table) {",
|
|
796
|
+
" $table->uuid('id')->primary();",
|
|
797
|
+
` $table->unsignedBigInteger(${q(p.workspace.foreignKey)})->nullable()->index();`,
|
|
798
|
+
" $table->string('type');",
|
|
799
|
+
" $table->morphs('notifiable');",
|
|
800
|
+
" $table->text('data');",
|
|
801
|
+
" $table->timestamp('read_at')->nullable();",
|
|
802
|
+
' $table->timestamps();',
|
|
803
|
+
' });',
|
|
804
|
+
' }',
|
|
805
|
+
'',
|
|
806
|
+
' public function down(): void',
|
|
807
|
+
' {',
|
|
808
|
+
" Schema::dropIfExists('notifications');",
|
|
809
|
+
' }',
|
|
810
|
+
'};',
|
|
811
|
+
'',
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** The first user, for the seeder. Read through config, so seeding works after config:cache (as on a deployed server). */
|
|
816
|
+
function adminConfig(p) {
|
|
817
|
+
return lines(
|
|
818
|
+
'<?php',
|
|
819
|
+
'',
|
|
820
|
+
'// The first person who can sign in (database/seeders/DatabaseSeeder.php), from .env.',
|
|
821
|
+
'return [',
|
|
822
|
+
` 'name' => env('OKJT_ADMIN_NAME', ${q(`${p.name} admin`)}),`,
|
|
823
|
+
" 'email' => env('OKJT_ADMIN_EMAIL'),",
|
|
824
|
+
" 'password' => env('OKJT_ADMIN_PASSWORD'),",
|
|
825
|
+
'];',
|
|
826
|
+
'',
|
|
827
|
+
);
|
|
828
|
+
}
|