@plutocms/supabase 0.12.0 → 0.14.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 (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/FEATURES.md +2 -1
  3. package/README.md +36 -28
  4. package/app/components/access/DeleteRoleModal.vue +1 -1
  5. package/app/components/access/EditRolesSlideover.vue +1 -1
  6. package/app/components/access/InviteModal.vue +2 -2
  7. package/app/components/access/RevokeModal.vue +1 -1
  8. package/app/components/access/RoleSlideover.vue +2 -2
  9. package/app/composables/migrations.ts +25 -5
  10. package/app/pages/admin/(auth)/signup.vue +10 -3
  11. package/app/pages/admin/settings/roles.vue +1 -1
  12. package/app/pages/admin/settings/users.vue +5 -5
  13. package/app/pages/admin/setup.vue +18 -16
  14. package/app/plugins/pluto-extension.ts +8 -8
  15. package/app/utils/api-failure.ts +16 -0
  16. package/app/utils/auth-driver.ts +2 -2
  17. package/i18n/locales/en.json +8 -3
  18. package/i18n/locales/pt-BR.json +8 -3
  19. package/package.json +3 -3
  20. package/server/api/{access → _pluto/supabase/access}/capabilities.get.ts +3 -3
  21. package/server/api/{access → _pluto/supabase/access}/roles/[key].delete.ts +2 -2
  22. package/server/api/{access → _pluto/supabase/access}/roles/[key].put.ts +4 -4
  23. package/server/api/{access → _pluto/supabase/access}/roles.get.ts +2 -2
  24. package/server/api/{access → _pluto/supabase/access}/roles.post.ts +4 -4
  25. package/server/api/{access → _pluto/supabase/access}/users/[id]/resend.post.ts +8 -8
  26. package/server/api/{access → _pluto/supabase/access}/users/[id]/roles.delete.ts +3 -3
  27. package/server/api/{access → _pluto/supabase/access}/users/[id]/roles.put.ts +4 -4
  28. package/server/api/{access → _pluto/supabase/access}/users/invite-link.post.ts +6 -6
  29. package/server/api/{access → _pluto/supabase/access}/users/invite.post.ts +8 -8
  30. package/server/api/{access → _pluto/supabase/access}/users.get.ts +2 -2
  31. package/server/api/{auth → _pluto/supabase/auth}/signup.post.ts +10 -5
  32. package/server/api/{profile → _pluto/supabase/me}/locale.get.ts +1 -1
  33. package/server/api/{profile/locale.post.ts → _pluto/supabase/me/locale.put.ts} +7 -2
  34. package/server/api/{migrations/status.get.ts → _pluto/supabase/migrations/index.get.ts} +2 -2
  35. package/server/api/{migrations → _pluto/supabase/migrations}/run.post.ts +21 -16
  36. package/server/api/{settings/update.post.ts → _pluto/supabase/settings/index.put.ts} +8 -4
  37. package/server/api/{settings/first_setup.get.ts → _pluto/supabase/setup/index.get.ts} +6 -5
  38. package/server/api/{setup/create.post.ts → _pluto/supabase/setup/index.post.ts} +13 -10
  39. package/server/utils/admin-guard.ts +1 -1
  40. package/server/utils/content-adapter.ts +111 -6
  41. package/server/utils/pending-migrations.ts +0 -0
  42. package/server/api/users/[id].get.ts +0 -26
  43. package/server/api/users/index.get.ts +0 -21
  44. /package/server/api/{permissions/me.get.ts → _pluto/supabase/me/capabilities.get.ts} +0 -0
  45. /package/server/api/{settings → _pluto/supabase/settings}/index.get.ts +0 -0
@@ -1,16 +1,16 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
- import { mapAccessError } from '../../../../utils/access-errors'
3
- import { inviteRedirectUrl, isEmailExistsError, requireUserId, resendBodySchema } from '../../../../utils/access-invite'
2
+ import { mapAccessError } from '../../../../../../utils/access-errors'
3
+ import { inviteRedirectUrl, isEmailExistsError, requireUserId, resendBodySchema } from '../../../../../../utils/access-invite'
4
4
  import {
5
5
  assertInviteLocale,
6
6
  generateAcceptLink,
7
7
  inviterNameOf,
8
8
  plutoMailReady,
9
9
  sendInviteMail,
10
- } from '../../../../utils/access-mail'
11
- import { requireAdminRole } from '../../../../utils/admin-role-guard'
12
- import { enforceInviteRateLimit } from '../../../../utils/rate-limit'
13
- import { optionalServiceClient } from '../../../../utils/service-client'
10
+ } from '../../../../../../utils/access-mail'
11
+ import { requireAdminRole } from '../../../../../../utils/admin-role-guard'
12
+ import { enforceInviteRateLimit } from '../../../../../../utils/rate-limit'
13
+ import { optionalServiceClient } from '../../../../../../utils/service-client'
14
14
  import {
15
15
  emailInvalidError,
16
16
  emailNotAuthorizedError,
@@ -20,8 +20,8 @@ import {
20
20
  isEmailRateLimitError,
21
21
  sendSignInLink,
22
22
  signInLinkError,
23
- } from '../../../../utils/sign-in-link'
24
- import { parseBody } from '../../../../utils/validate'
23
+ } from '../../../../../../utils/sign-in-link'
24
+ import { parseBody } from '../../../../../../utils/validate'
25
25
 
26
26
  function notFound() {
27
27
  return createPlutoError({
@@ -1,7 +1,7 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
- import { mapAccessError } from '../../../../utils/access-errors'
3
- import { requireUserId } from '../../../../utils/access-invite'
4
- import { requireAdminRole } from '../../../../utils/admin-role-guard'
2
+ import { mapAccessError } from '../../../../../../utils/access-errors'
3
+ import { requireUserId } from '../../../../../../utils/access-invite'
4
+ import { requireAdminRole } from '../../../../../../utils/admin-role-guard'
5
5
 
6
6
  /** Revokes the access of a user: it removes every role. The account stays. */
7
7
  export default defineEventHandler(async (event) => {
@@ -1,8 +1,8 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
- import { mapAccessError } from '../../../../utils/access-errors'
3
- import { requireUserId, rolesBodySchema } from '../../../../utils/access-invite'
4
- import { requireAdminRole } from '../../../../utils/admin-role-guard'
5
- import { parseBody } from '../../../../utils/validate'
2
+ import { mapAccessError } from '../../../../../../utils/access-errors'
3
+ import { requireUserId, rolesBodySchema } from '../../../../../../utils/access-invite'
4
+ import { requireAdminRole } from '../../../../../../utils/admin-role-guard'
5
+ import { parseBody } from '../../../../../../utils/validate'
6
6
 
7
7
  /** Replaces the roles of a user. An empty list revokes the access. */
8
8
  export default defineEventHandler(async (event) => {
@@ -1,10 +1,10 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
- import { inviteBodySchema, inviteRedirectUrl } from '../../../utils/access-invite'
3
- import { provisionAccess, serviceKeyRequiredError } from '../../../utils/access-mail'
4
- import { requireAdminRole } from '../../../utils/admin-role-guard'
5
- import { enforceInviteRateLimit } from '../../../utils/rate-limit'
6
- import { optionalServiceClient } from '../../../utils/service-client'
7
- import { parseBody } from '../../../utils/validate'
2
+ import { inviteBodySchema, inviteRedirectUrl } from '../../../../../utils/access-invite'
3
+ import { provisionAccess, serviceKeyRequiredError } from '../../../../../utils/access-mail'
4
+ import { requireAdminRole } from '../../../../../utils/admin-role-guard'
5
+ import { enforceInviteRateLimit } from '../../../../../utils/rate-limit'
6
+ import { optionalServiceClient } from '../../../../../utils/service-client'
7
+ import { parseBody } from '../../../../../utils/validate'
8
8
 
9
9
  /**
10
10
  * Grants the roles and returns a one-time sign-in link. No email is sent, so
@@ -1,10 +1,10 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
- import { mapAccessError } from '../../../utils/access-errors'
2
+ import { mapAccessError } from '../../../../../utils/access-errors'
3
3
  import {
4
4
  inviteBodySchema,
5
5
  inviteRedirectUrl,
6
6
  isEmailExistsError,
7
- } from '../../../utils/access-invite'
7
+ } from '../../../../../utils/access-invite'
8
8
  import {
9
9
  addRoles,
10
10
  assertInviteLocale,
@@ -13,10 +13,10 @@ import {
13
13
  plutoMailReady,
14
14
  provisionAccess,
15
15
  sendInviteMail,
16
- } from '../../../utils/access-mail'
17
- import { requireAdminRole } from '../../../utils/admin-role-guard'
18
- import { enforceInviteRateLimit } from '../../../utils/rate-limit'
19
- import { optionalServiceClient } from '../../../utils/service-client'
16
+ } from '../../../../../utils/access-mail'
17
+ import { requireAdminRole } from '../../../../../utils/admin-role-guard'
18
+ import { enforceInviteRateLimit } from '../../../../../utils/rate-limit'
19
+ import { optionalServiceClient } from '../../../../../utils/service-client'
20
20
  import {
21
21
  emailInvalidError,
22
22
  emailNotAuthorizedError,
@@ -26,8 +26,8 @@ import {
26
26
  isEmailRateLimitError,
27
27
  sendSignInLink,
28
28
  signInLinkError,
29
- } from '../../../utils/sign-in-link'
30
- import { parseBody } from '../../../utils/validate'
29
+ } from '../../../../../utils/sign-in-link'
30
+ import { parseBody } from '../../../../../utils/validate'
31
31
 
32
32
  /**
33
33
  * Invites a user by email and grants the roles.
@@ -1,6 +1,6 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
- import { mapAccessError } from '../../utils/access-errors'
3
- import { requireCapability } from '../../utils/capability-guard'
2
+ import { mapAccessError } from '../../../../utils/access-errors'
3
+ import { requireCapability } from '../../../../utils/capability-guard'
4
4
 
5
5
  /** The staff: users with a role, or with the admin flag. */
6
6
  export default defineEventHandler(async (event) => {
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { serverSupabaseClient } from '#supabase/server'
3
- import { parseBody } from '../../utils/validate'
3
+ import { parseBody } from '../../../../utils/validate'
4
4
 
5
5
  // Supabase Auth (GoTrue) rejects a password under 6 characters with its own
6
6
  // opaque error. Matching that minimum here turns an empty/short password
@@ -30,11 +30,16 @@ export default defineEventHandler(async (event) => {
30
30
  })
31
31
 
32
32
  if (error) {
33
- return {
34
- success: false,
33
+ // A 4xx from Supabase Auth keeps its status (for example 429 for a rate
34
+ // limit). Any other status becomes a 502. The code goes in `data`, so
35
+ // the signup page can show the translated text of a known code.
36
+ const status = error.status ?? 400
37
+ throw createPlutoError({
38
+ statusCode: status >= 400 && status < 500 ? status : 502,
35
39
  message: error.message,
36
- error,
37
- }
40
+ key: 'supabase.errors.signupFailed',
41
+ data: { code: error.code },
42
+ })
38
43
  }
39
44
 
40
45
  return {
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
27
27
  // upgraded this package but not yet applied that migration has no such
28
28
  // column. That state is expected and temporary, so degrade to
29
29
  // "Automatic" instead of a 500. The same choice is made in
30
- // server/api/permissions/me.get.ts.
30
+ // server/api/_pluto/supabase/me/capabilities.get.ts.
31
31
  console.error(
32
32
  'profiles.locale read failed, returning no locale:',
33
33
  error.message
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
3
- import { parseBody } from '../../utils/validate'
3
+ import { parseBody } from '../../../../utils/validate'
4
4
 
5
5
  const localeSchema = z.object({
6
6
  locale: z.string().nullable(),
@@ -64,7 +64,12 @@ export default defineEventHandler(async (event) => {
64
64
  })
65
65
  }
66
66
 
67
- throw createError({ statusCode: 500, statusMessage: error.message })
67
+ throw createPlutoError({
68
+ statusCode: 500,
69
+ message: `Could not save your language: ${error.message}`,
70
+ key: 'supabase.errors.localeSave',
71
+ params: { message: error.message },
72
+ })
68
73
  }
69
74
 
70
75
  return { locale }
@@ -1,5 +1,5 @@
1
- import { requireAdmin } from '../../utils/admin-guard'
2
- import { getMigrationStatus } from '../../utils/pending-migrations'
1
+ import { requireAdmin } from '../../../../utils/admin-guard'
2
+ import { getMigrationStatus } from '../../../../utils/pending-migrations'
3
3
 
4
4
  export default defineEventHandler(async (event) => {
5
5
  // requireAdmin specifically, not a named capability — see admin-guard.ts
@@ -1,12 +1,12 @@
1
- import type { PlutoMigrationFile } from '../../../shared/types/migrations'
2
- import type { MigrationFileResult } from '../../utils/migrations'
1
+ import type { PlutoMigrationFile } from '../../../../../shared/types/migrations'
2
+ import type { MigrationFileResult } from '../../../../utils/migrations'
3
3
  import { z } from 'zod'
4
- import { requireAdmin } from '../../utils/admin-guard'
5
- import { persistDatabaseUrl } from '../../utils/env-file'
6
- import { resolveConnectionString, runPendingMigrations } from '../../utils/migrations'
7
- import { scrubConnectionString } from '../../utils/scrub-connection-string'
8
- import { regenerateSupabaseTypes } from '../../utils/typegen'
9
- import { parseBody } from '../../utils/validate'
4
+ import { requireAdmin } from '../../../../utils/admin-guard'
5
+ import { persistDatabaseUrl } from '../../../../utils/env-file'
6
+ import { resolveConnectionString, runPendingMigrations } from '../../../../utils/migrations'
7
+ import { scrubConnectionString } from '../../../../utils/scrub-connection-string'
8
+ import { regenerateSupabaseTypes } from '../../../../utils/typegen'
9
+ import { parseBody } from '../../../../utils/validate'
10
10
 
11
11
  // The whole body is optional: an already-configured DATABASE_URL means the
12
12
  // caller need not (and, per resolveConnectionString, cannot) supply one.
@@ -33,10 +33,12 @@ export default defineEventHandler(async (event) => {
33
33
  resolveConnectionString(body?.connectionString)
34
34
 
35
35
  if (!connStr) {
36
- return {
37
- success: false as const,
38
- needsConnectionString: true,
39
- }
36
+ throw createPlutoError({
37
+ statusCode: 400,
38
+ message: 'A database connection string is required.',
39
+ key: 'supabase.errors.connectionStringRequired',
40
+ data: { needsConnectionString: true },
41
+ })
40
42
  }
41
43
 
42
44
  const config = useRuntimeConfig()
@@ -102,9 +104,12 @@ export default defineEventHandler(async (event) => {
102
104
 
103
105
  console.error('Error applying migrations:', errorMessage)
104
106
 
105
- return {
106
- success: false as const,
107
- error: errorMessage,
108
- }
107
+ throw createPlutoError({
108
+ statusCode: 500,
109
+ message: `Migrations failed: ${errorMessage}`,
110
+ key: 'supabase.errors.migrationsFailed',
111
+ params: { message: errorMessage },
112
+ data: { error: errorMessage },
113
+ })
109
114
  }
110
115
  })
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod'
2
2
  import { serverSupabaseClient } from '#supabase/server'
3
- import { requireCapability } from '../../utils/capability-guard'
4
- import { parseBody } from '../../utils/validate'
3
+ import { requireCapability } from '../../../../utils/capability-guard'
4
+ import { parseBody } from '../../../../utils/validate'
5
5
 
6
6
  // A key is lowercase segments separated by dots, e.g. 'website_title' or
7
7
  // 'blog.posts_per_page'. Mirrors the settings_setting_key_format check
@@ -39,12 +39,16 @@ export default defineEventHandler(async (event) => {
39
39
  .select()
40
40
 
41
41
  if (error) {
42
- throw createError({ statusMessage: error.message })
42
+ throw createPlutoError({
43
+ statusCode: 500,
44
+ message: `Could not save the settings: ${error.message}`,
45
+ key: 'supabase.errors.settingsSave',
46
+ params: { message: error.message },
47
+ })
43
48
  }
44
49
 
45
50
  return {
46
51
  message: 'Settings updated successfully.',
47
- statusCode: 200,
48
52
  data,
49
53
  }
50
54
  })
@@ -9,11 +9,12 @@ export default defineEventHandler(async (event) => {
9
9
  const { data, error } = await client.from('healthcheck').select('*')
10
10
 
11
11
  if (error) {
12
- return {
13
- success: false as const,
14
- message: 'Error retrieving healthcheck settings',
15
- error: error.message,
16
- }
12
+ throw createPlutoError({
13
+ statusCode: 500,
14
+ message: `Could not read the setup state: ${error.message}`,
15
+ key: 'supabase.errors.setupStateRead',
16
+ params: { message: error.message },
17
+ })
17
18
  }
18
19
 
19
20
  // @ts-expect-error: Object.fromEntries may not infer the correct type
@@ -1,10 +1,10 @@
1
- import type { PlutoMigrationFile } from '../../../shared/types/migrations'
1
+ import type { PlutoMigrationFile } from '../../../../../shared/types/migrations'
2
2
  import postgres from 'postgres'
3
3
  import { z } from 'zod'
4
- import { persistDatabaseUrl } from '../../utils/env-file'
5
- import { runPendingMigrations } from '../../utils/migrations'
6
- import { scrubConnectionString } from '../../utils/scrub-connection-string'
7
- import { parseBody } from '../../utils/validate'
4
+ import { persistDatabaseUrl } from '../../../../utils/env-file'
5
+ import { runPendingMigrations } from '../../../../utils/migrations'
6
+ import { scrubConnectionString } from '../../../../utils/scrub-connection-string'
7
+ import { parseBody } from '../../../../utils/validate'
8
8
 
9
9
  // Only checks that a connection string was actually sent. A Postgres
10
10
  // connection string can take many valid forms (postgres:// URL, key=value
@@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
20
20
  // before setup creates the profiles table — so once real setup has run,
21
21
  // this check is the only thing stopping anyone from re-running it against
22
22
  // an arbitrary database. Mirrors the same precedence rule in
23
- // server/api/migrations/run.post.ts, which calls it "the whole security
23
+ // server/api/_pluto/supabase/migrations/run.post.ts, which calls it "the whole security
24
24
  // model for this endpoint."
25
25
  if (process.env.DATABASE_URL) {
26
26
  throw createPlutoError({
@@ -120,9 +120,12 @@ export default defineEventHandler(async (event) => {
120
120
 
121
121
  console.error('Error setting up the database:', message)
122
122
 
123
- return {
124
- success: false,
125
- error: message,
126
- }
123
+ throw createPlutoError({
124
+ statusCode: 500,
125
+ message: `Database setup failed: ${message}`,
126
+ key: 'supabase.errors.setupFailed',
127
+ params: { message },
128
+ data: { error: message },
129
+ })
127
130
  }
128
131
  })
@@ -10,7 +10,7 @@ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
10
10
  * `004_roles_and_capabilities.sql` respectively); a genuinely fresh site
11
11
  * that has never applied any migration beyond the original baseline
12
12
  * schema — an expected, normal state, not a broken one — has neither
13
- * function. `/api/migrations/*` (the only routes that call `requireAdmin`
13
+ * function. `/api/_pluto/supabase/migrations/*` (the only routes that call `requireAdmin`
14
14
  * today) are the one place this actually bites: they are the routes that
15
15
  * apply a pending migration, so they must work with zero migrations
16
16
  * applied, not just with `002`+ already in place. An earlier version of
@@ -1,5 +1,6 @@
1
1
  import type { PostgrestError, SupabaseClient } from '@supabase/supabase-js'
2
2
  import type { H3Event } from 'h3'
3
+ import { createClient } from '@supabase/supabase-js'
3
4
  import { serverSupabaseClient } from '#supabase/server'
4
5
  import { requireCapability } from './capability-guard'
5
6
 
@@ -31,9 +32,44 @@ import { requireCapability } from './capability-guard'
31
32
  * type, not to assert against one.
32
33
  */
33
34
  async function getClient(event: H3Event): Promise<SupabaseClient> {
35
+ if (isPublicRead(event)) {
36
+ return getAnonymousClient(event)
37
+ }
38
+
34
39
  return (await serverSupabaseClient(event)) as unknown as SupabaseClient
35
40
  }
36
41
 
42
+ /** The anonymous client and the url/key pair it was built from. */
43
+ let anonymousClient: { url: string, key: string, client: SupabaseClient } | undefined
44
+
45
+ /**
46
+ * A Supabase client with no user session, for public reads of the public
47
+ * API. It uses the anon key from the runtime config of `@nuxtjs/supabase`,
48
+ * reads no cookies and keeps no session. Row Level Security then applies as
49
+ * for an anonymous visitor, so the answer is the same for everyone and a CDN
50
+ * can cache it. It never uses the service role key. One client serves the
51
+ * whole process.
52
+ */
53
+ function getAnonymousClient(event: H3Event): SupabaseClient {
54
+ const { url, key } = useRuntimeConfig(event).public.supabase as { url: string, key: string }
55
+
56
+ if (!anonymousClient || anonymousClient.url !== url || anonymousClient.key !== key) {
57
+ anonymousClient = {
58
+ url,
59
+ key,
60
+ client: createClient(url, key, {
61
+ auth: {
62
+ persistSession: false,
63
+ autoRefreshToken: false,
64
+ detectSessionInUrl: false,
65
+ },
66
+ }),
67
+ }
68
+ }
69
+
70
+ return anonymousClient.client
71
+ }
72
+
37
73
  /**
38
74
  * Resolves the storage column for a content type's created/updated
39
75
  * timestamp. Returns `undefined` when timestamps are disabled entirely
@@ -70,6 +106,70 @@ export function resolveStatusColumn(type: PlutoContentType): string | undefined
70
106
  return type.status.column ?? 'status'
71
107
  }
72
108
 
109
+ /**
110
+ * Resolves a sort field to its column. A timestamp column (the created, the
111
+ * updated or the published-at column) sorts directly, because it is not a
112
+ * declared field. Any other name must be a declared field.
113
+ */
114
+ function resolveSortColumn(type: PlutoContentType, fieldName: string): string {
115
+ if (timestampColumns(type).includes(fieldName)) {
116
+ return fieldName
117
+ }
118
+
119
+ return resolveFieldColumn(type, fieldName, 'sort')
120
+ }
121
+
122
+ /** The columns of the created, updated and published-at timestamps that this type has. */
123
+ function timestampColumns(type: PlutoContentType): string[] {
124
+ const { created, updated, published } = timestampKeys(type)
125
+
126
+ return [created, updated, published].filter((column): column is string => column !== undefined)
127
+ }
128
+
129
+ /**
130
+ * The item keys of the timestamps of a type. They are the column names,
131
+ * because a timestamp is not a declared field. A key is missing when its
132
+ * timestamp is off. Core uses this to sort and to show the public dates.
133
+ */
134
+ function timestampKeys(type: PlutoContentType): { created?: string, updated?: string, published?: string } {
135
+ const keys: { created?: string, updated?: string, published?: string } = {}
136
+
137
+ const created = resolveTimestampColumn(type, 'created')
138
+ if (created) {
139
+ keys.created = created
140
+ }
141
+
142
+ const updated = resolveTimestampColumn(type, 'updated')
143
+ if (updated) {
144
+ keys.updated = updated
145
+ }
146
+
147
+ const published = type.status ? type.status.publishedAtColumn : undefined
148
+ if (published) {
149
+ keys.published = published
150
+ }
151
+
152
+ return keys
153
+ }
154
+
155
+ /**
156
+ * Maps a row to an item, then copies the timestamp columns onto it under
157
+ * their column names. `mapColumnsToFields` drops every column that no field
158
+ * declares, and a timestamp is not a field. A field with the same name as a
159
+ * timestamp column keeps its own value.
160
+ */
161
+ function toItem(type: PlutoContentType, row: Record<string, unknown>): PlutoContentItem {
162
+ const item = mapColumnsToFields(type, row)
163
+
164
+ for (const column of timestampColumns(type)) {
165
+ if (!Object.hasOwn(item, column) && Object.hasOwn(row, column)) {
166
+ item[column] = row[column]
167
+ }
168
+ }
169
+
170
+ return item
171
+ }
172
+
73
173
  /**
74
174
  * Maps a raw PostgREST error to an H3/Nuxt error safe to send to a
75
175
  * client. Logs the raw error first, so the real message always reaches
@@ -128,6 +228,10 @@ async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise
128
228
  builder = builder.eq(statusColumn, publishedValue)
129
229
  }
130
230
 
231
+ for (const [field, value] of Object.entries(query.filters ?? {})) {
232
+ builder = builder.eq(resolveFieldColumn(ctx.type, field, 'filter'), value)
233
+ }
234
+
131
235
  if (query.search) {
132
236
  const titleColumn = resolveFieldColumn(ctx.type, ctx.type.titleField, 'titleField')
133
237
  builder = builder.ilike(titleColumn, `%${query.search}%`)
@@ -135,7 +239,7 @@ async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise
135
239
 
136
240
  const sort = query.sort ?? ctx.type.defaultSort
137
241
  if (sort) {
138
- const sortColumn = resolveFieldColumn(ctx.type, sort.field, 'sort')
242
+ const sortColumn = resolveSortColumn(ctx.type, sort.field)
139
243
  builder = builder.order(sortColumn, { ascending: sort.direction !== 'desc' })
140
244
  } else {
141
245
  const createdColumn = resolveTimestampColumn(ctx.type, 'created')
@@ -156,7 +260,7 @@ async function list(ctx: PlutoContentContext, query: PlutoContentQuery): Promise
156
260
  }
157
261
 
158
262
  return {
159
- data: (data ?? []).map((row: Record<string, unknown>) => mapColumnsToFields(ctx.type, row)),
263
+ data: (data ?? []).map((row: Record<string, unknown>) => toItem(ctx.type, row)),
160
264
  total: count ?? undefined,
161
265
  }
162
266
  }
@@ -190,7 +294,7 @@ async function get(ctx: PlutoContentContext, idOrSlug: string | number): Promise
190
294
  }
191
295
 
192
296
  if (data) {
193
- return mapColumnsToFields(ctx.type, data)
297
+ return toItem(ctx.type, data)
194
298
  }
195
299
 
196
300
  if (ctx.type.slug) {
@@ -207,7 +311,7 @@ async function get(ctx: PlutoContentContext, idOrSlug: string | number): Promise
207
311
  }
208
312
 
209
313
  if (bySlug.data) {
210
- return mapColumnsToFields(ctx.type, bySlug.data)
314
+ return toItem(ctx.type, bySlug.data)
211
315
  }
212
316
  }
213
317
 
@@ -252,7 +356,7 @@ async function create(ctx: PlutoContentContext, values: Record<string, unknown>)
252
356
  throw toContentError(error)
253
357
  }
254
358
 
255
- return mapColumnsToFields(ctx.type, data)
359
+ return toItem(ctx.type, data)
256
360
  }
257
361
 
258
362
  async function update(
@@ -319,7 +423,7 @@ async function update(
319
423
  throw toContentError(error)
320
424
  }
321
425
 
322
- return mapColumnsToFields(ctx.type, data)
426
+ return toItem(ctx.type, data)
323
427
  }
324
428
 
325
429
  async function remove(ctx: PlutoContentContext, id: string | number): Promise<void> {
@@ -378,5 +482,6 @@ export function createSupabaseContentAdapter(): PlutoContentAdapter {
378
482
  remove,
379
483
  authorize,
380
484
  countCreated,
485
+ timestampKeys,
381
486
  }
382
487
  }
Binary file
@@ -1,26 +0,0 @@
1
- import { serverSupabaseClient } from '#supabase/server'
2
- import { requireCapability } from '../../utils/capability-guard'
3
-
4
- export default defineEventHandler(async (event) => {
5
- await requireCapability(event, 'users:read')
6
-
7
- const client = await serverSupabaseClient<Database>(event)
8
-
9
- const id = event.context.params?.id as string
10
-
11
- const { data: user, error } = await client
12
- .from('profiles')
13
- .select('*')
14
- .eq('id', id)
15
-
16
- if (error) {
17
- throw createPlutoError({
18
- statusCode: 500,
19
- message: `Error fetching user: ${error.message}`,
20
- key: 'supabase.errors.fetchUser',
21
- params: { message: error.message },
22
- })
23
- }
24
-
25
- return user
26
- })
@@ -1,21 +0,0 @@
1
- import { serverSupabaseClient } from '#supabase/server'
2
- import { requireCapability } from '../../utils/capability-guard'
3
-
4
- export default defineEventHandler(async (event) => {
5
- await requireCapability(event, 'users:read')
6
-
7
- const client = await serverSupabaseClient<Database>(event)
8
-
9
- const { data: users, error } = await client.from('profiles').select('*')
10
-
11
- if (error) {
12
- throw createPlutoError({
13
- statusCode: 500,
14
- message: `Error fetching users: ${error.message}`,
15
- key: 'supabase.errors.fetchUsers',
16
- params: { message: error.message },
17
- })
18
- }
19
-
20
- return users
21
- })