From de067b7be82615406918d177c548451356292798 Mon Sep 17 00:00:00 2001 From: Gabriel Abreu Date: Sun, 8 Feb 2026 12:48:47 -0300 Subject: [PATCH 1/7] Add _app.js for Pages Router Relay provider Pages in the pages/ directory need their own provider wrapper since app/layout.js only covers App Router routes in Next.js 13+. Co-Authored-By: Claude Opus 4.6 --- pages/_app.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 pages/_app.js diff --git a/pages/_app.js b/pages/_app.js new file mode 100644 index 0000000..85ee8b7 --- /dev/null +++ b/pages/_app.js @@ -0,0 +1,25 @@ +import '../app/globals.css'; +import { Providers } from '../app/providers'; +import EmotionRegistry from '../app/emotion-registry'; + +function App({ Component, pageProps, authToken }) { + return ( + + + + + + ); +} + +App.getInitialProps = async ({ ctx }) => { + // Read auth token from cookies on the server + const authToken = ctx.req?.headers.cookie + ?.split(';') + .find(c => c.trim().startsWith('spacy_auth=')) + ?.split('=')[1]; + + return { authToken }; +}; + +export default App; From 0ef3ea8ea2252ed8800b224adbda838504ff6ce9 Mon Sep 17 00:00:00 2001 From: Gabriel Abreu Date: Sun, 8 Feb 2026 13:01:07 -0300 Subject: [PATCH 2/7] Add x-hasura-user-id claim to JWT with decoded Relay UUID The articles table insert permission uses a column preset that sets author_id from x-hasura-user-id session variable. Added this claim to both login and signup JWT generation, decoding the Relay global ID to extract the raw UUID. Co-Authored-By: Claude Opus 4.6 --- pages/api/login.js | 3 ++ pages/api/sign_up.js | 3 ++ .../default/tables/public_articles.yaml | 33 ++++++++++++++++--- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/pages/api/login.js b/pages/api/login.js index fa0b992..ef97322 100644 --- a/pages/api/login.js +++ b/pages/api/login.js @@ -41,6 +41,8 @@ export default async function (req, res) { error: "Invalid login", }); } else { + // Decode Relay global ID (["public", "users", uuid]) to get the raw UUID + const userId = JSON.parse(Buffer.from(user.node.id, "base64").toString())[3]; const token = jwt.sign( { email, @@ -48,6 +50,7 @@ export default async function (req, res) { "x-hasura-allowed-roles": ["user"], "x-hasura-default-role": "user", "x-hasura-user-email": email, + "x-hasura-user-id": userId, }, }, process.env.HASURA_SECRET, diff --git a/pages/api/sign_up.js b/pages/api/sign_up.js index 1fe14c4..b1296c4 100644 --- a/pages/api/sign_up.js +++ b/pages/api/sign_up.js @@ -33,6 +33,8 @@ export default async function (req, res) { }); } + // Decode Relay global ID (["public", "users", uuid]) to get the raw UUID + const userId = JSON.parse(Buffer.from(result.data.insertUsersOne.id, "base64").toString())[3]; const token = jwt.sign( { email: req.body.input.input.email, @@ -40,6 +42,7 @@ export default async function (req, res) { "x-hasura-allowed-roles": ["user"], "x-hasura-default-role": "user", "x-hasura-user-email": input.email, + "x-hasura-user-id": userId, }, }, process.env.HASURA_SECRET, diff --git a/spacy-server/metadata/databases/default/tables/public_articles.yaml b/spacy-server/metadata/databases/default/tables/public_articles.yaml index 1c43585..98ee798 100644 --- a/spacy-server/metadata/databases/default/tables/public_articles.yaml +++ b/spacy-server/metadata/databases/default/tables/public_articles.yaml @@ -1,29 +1,53 @@ table: name: articles schema: public +object_relationships: + - name: user + using: + foreign_key_constraint_on: author_id insert_permissions: - role: user permission: check: {} + set: + author_id: x-hasura-User-Id columns: + - author_id - body - - intro - created_at - - updated_at - - author_id - id + - intro + - slug + - title + - updated_at comment: "" select_permissions: - - role: user + - role: anonymous permission: columns: - body - intro + - slug + - title - created_at - updated_at - author_id - id filter: {} + limit: 0 + comment: "" + - role: user + permission: + columns: + - author_id + - body + - created_at + - id + - intro + - slug + - title + - updated_at + filter: {} comment: "" update_permissions: - role: user @@ -32,6 +56,7 @@ update_permissions: - body - created_at - intro + - title - updated_at filter: {} check: null From 785a10c2f31923e8866de4d6e7ca74acd878be6a Mon Sep 17 00:00:00 2001 From: Gabriel Abreu Date: Sun, 4 Jan 2026 23:45:03 -0300 Subject: [PATCH 3/7] Step 1: Define GraphQL mutation for creating articles --- schema.graphql | 43 ++++++++++++++++++++++++++++++++ src/pages/article/NewArticle.res | 9 +++++++ 2 files changed, 52 insertions(+) diff --git a/schema.graphql b/schema.graphql index d2ae92a..02da1b2 100644 --- a/schema.graphql +++ b/schema.graphql @@ -22,7 +22,12 @@ type Articles implements Node { createdAt: timestamptz! id: ID! intro: String! + slug: String! + title: String! updatedAt: timestamptz! + + """An object relationship""" + user: Users! } """ @@ -37,7 +42,10 @@ input ArticlesBoolExp { createdAt: TimestamptzComparisonExp id: UuidComparisonExp intro: StringComparisonExp + slug: StringComparisonExp + title: StringComparisonExp updatedAt: TimestamptzComparisonExp + user: UsersBoolExp } """ @@ -56,6 +64,11 @@ enum ArticlesConstraint { unique or primary key constraint on columns "id" """ articles_pkey + + """ + unique or primary key constraint on columns "slug" + """ + articles_slug_key } type ArticlesEdge { @@ -72,7 +85,10 @@ input ArticlesInsertInput { createdAt: timestamptz id: uuid intro: String + slug: String + title: String updatedAt: timestamptz + user: UsersObjRelInsertInput } """ @@ -102,7 +118,10 @@ input ArticlesOrderBy { createdAt: OrderBy id: OrderBy intro: OrderBy + slug: OrderBy + title: OrderBy updatedAt: OrderBy + user: UsersOrderBy } """primary key columns input for table: articles""" @@ -129,6 +148,12 @@ enum ArticlesSelectColumn { """column name""" intro + """column name""" + slug + + """column name""" + title + """column name""" updatedAt } @@ -142,6 +167,8 @@ input ArticlesSetInput { createdAt: timestamptz id: uuid intro: String + slug: String + title: String updatedAt: timestamptz } @@ -164,6 +191,12 @@ enum ArticlesUpdateColumn { """column name""" intro + """column name""" + slug + + """column name""" + title + """column name""" updatedAt } @@ -610,6 +643,16 @@ type UsersMutationResponse { returning: [Users!]! } +""" +input type for inserting object relation for remote table "users" +""" +input UsersObjRelInsertInput { + data: UsersInsertInput! + + """upsert condition""" + onConflict: UsersOnConflict +} + """ on_conflict condition type for table "users" """ diff --git a/src/pages/article/NewArticle.res b/src/pages/article/NewArticle.res index 01c77e8..8f6d747 100644 --- a/src/pages/article/NewArticle.res +++ b/src/pages/article/NewArticle.res @@ -6,6 +6,15 @@ open AncestorSpacy +module CreateArticleMutation = %relay(` +mutation NewArticleMutation($input: ArticlesInsertInput!) { + insertArticlesOne(object: $input) { + id + slug + } +} +`) + module FormFields = %lenses( type state = { title: string, From d6d273854a52847d778020dd837f8bd58138dc46 Mon Sep 17 00:00:00 2001 From: Gabriel Abreu Date: Sun, 4 Jan 2026 23:45:12 -0300 Subject: [PATCH 4/7] Step 2: Add query to fetch current user --- src/pages/article/NewArticle.res | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/pages/article/NewArticle.res b/src/pages/article/NewArticle.res index 8f6d747..102c793 100644 --- a/src/pages/article/NewArticle.res +++ b/src/pages/article/NewArticle.res @@ -15,6 +15,18 @@ mutation NewArticleMutation($input: ArticlesInsertInput!) { } `) +module Query = %relay(` +query NewArticleQuery { + usersConnection(first: 1) { + edges { + node { + id + } + } + } +} +`) + module FormFields = %lenses( type state = { title: string, From 367b6508d4c3733677721c40e3c45c98cfa058c3 Mon Sep 17 00:00:00 2001 From: Gabriel Abreu Date: Sun, 4 Jan 2026 23:45:23 -0300 Subject: [PATCH 5/7] Step 3: Add helper function to generate URL slugs from titles --- src/pages/article/NewArticle.res | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/pages/article/NewArticle.res b/src/pages/article/NewArticle.res index 102c793..53433b7 100644 --- a/src/pages/article/NewArticle.res +++ b/src/pages/article/NewArticle.res @@ -47,6 +47,14 @@ let formSchema = { ]) } +// Helper function to create a URL-friendly slug from a title +let createSlug = title => { + title + ->Js.String2.toLowerCase + ->Js.String2.replaceByRe(%re("/[^a-z0-9]+/g"), "-") + ->Js.String2.replaceByRe(%re("/^-+|-+$/g"), "") +} + let default = () => { let handleSubmit = (event: Form.onSubmitAPI) => { Js.log(event.state) From 2de82a553304025d677d7fa4e518b8bd4cf29dd5 Mon Sep 17 00:00:00 2001 From: Gabriel Abreu Date: Sun, 4 Jan 2026 23:45:34 -0300 Subject: [PATCH 6/7] Step 4: Hook up Relay mutation and query in component --- src/pages/article/NewArticle.res | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/pages/article/NewArticle.res b/src/pages/article/NewArticle.res index 53433b7..53f2995 100644 --- a/src/pages/article/NewArticle.res +++ b/src/pages/article/NewArticle.res @@ -55,7 +55,12 @@ let createSlug = title => { ->Js.String2.replaceByRe(%re("/^-+|-+$/g"), "") } -let default = () => { +@react.component +let make = () => { + let (mutate, _) = CreateArticleMutation.use() + let queryData = Query.use(~variables=(), ()) + let user = queryData.usersConnection.edges[0] + let handleSubmit = (event: Form.onSubmitAPI) => { Js.log(event.state) @@ -113,3 +118,5 @@ let default = () => { } + +let default = make From 547ff1a7f308f5891fcc39765c611892056757b8 Mon Sep 17 00:00:00 2001 From: Gabriel Abreu Date: Sun, 4 Jan 2026 23:45:46 -0300 Subject: [PATCH 7/7] Step 5: Implement form submission with mutation call and response handling --- src/pages/article/NewArticle.res | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/pages/article/NewArticle.res b/src/pages/article/NewArticle.res index 53f2995..477e225 100644 --- a/src/pages/article/NewArticle.res +++ b/src/pages/article/NewArticle.res @@ -62,7 +62,32 @@ let make = () => { let user = queryData.usersConnection.edges[0] let handleSubmit = (event: Form.onSubmitAPI) => { - Js.log(event.state) + let slug = createSlug(event.state.values.title) + + mutate( + ~variables={ + input: { + title: Some(event.state.values.title), + intro: Some(event.state.values.short), + body: Some(event.state.values.content), + slug: Some(slug), + authorId: None, + createdAt: None, + id: None, + updatedAt: None, + user: None + }, + }, + ~onCompleted=(response, _errors) => { + switch response.insertArticlesOne { + | Some(article) => + // Navigate to the article page + Js.log2("Article created with slug:", article.slug) + | None => Js.log("Failed to create article") + } + }, + (), + )->RescriptRelay.Disposable.ignore None }