diff --git a/apps/www/src/app/api/graphql/route.ts b/apps/www/src/app/api/graphql/route.ts index 77f452db..058c6d0f 100644 --- a/apps/www/src/app/api/graphql/route.ts +++ b/apps/www/src/app/api/graphql/route.ts @@ -35,9 +35,17 @@ const { handleRequest } = createYoga({ }), useResponseCache({ session: () => cookies().get(lucia.sessionCookieName)?.value, + invalidateViaMutation: false, + scopePerSchemaCoordinate: { + "Query.user": "PRIVATE", + "Query.note": "PRIVATE", + "Query.messages": "PRIVATE", + "Query.messagesFromCursor": "PRIVATE", + }, ttl: 5_000, - ttlPerType: { - Note: 10_000, + ttlPerSchemaCoordinate: { + "Query.notes": 30_000, + "Query.notesFromCursor": 30_000, }, }), useDisableIntrospection({ diff --git a/apps/www/src/app/inbox/components/received/messages.tsx b/apps/www/src/app/inbox/components/received/messages.tsx index 5cef529f..c5964da5 100644 --- a/apps/www/src/app/inbox/components/received/messages.tsx +++ b/apps/www/src/app/inbox/components/received/messages.tsx @@ -7,21 +7,12 @@ import { getClient } from "@/lib/gql/rsc"; import { ReceivedMessagesList } from "./list"; import { RECEIVED_MESSAGES_QUERY } from "../../queries"; -const getMessages = async () => { +export async function ReceivedMessages() { const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? ""; - if (!sessionId) { - return null; - } - - const res = await getClient(sessionId).query(RECEIVED_MESSAGES_QUERY, { + const result = await getClient(sessionId).query(RECEIVED_MESSAGES_QUERY, { type: "received", }); - return res; -}; - -export async function ReceivedMessages() { - const result = await getMessages(); const messages = result?.data?.messages; return ( diff --git a/apps/www/src/app/inbox/components/sent/messages.tsx b/apps/www/src/app/inbox/components/sent/messages.tsx index 284d8963..8f5b1af8 100644 --- a/apps/www/src/app/inbox/components/sent/messages.tsx +++ b/apps/www/src/app/inbox/components/sent/messages.tsx @@ -7,22 +7,11 @@ import { getClient } from "@/lib/gql/rsc"; import { SentMessagesList } from "./list"; import { SENT_MESSAGES_QUERY } from "../../queries"; -const getMessages = async () => { +export async function SentMessages() { const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? ""; - - if (!sessionId) { - return null; - } - - const res = await getClient(sessionId).query(SENT_MESSAGES_QUERY, { + const result = await getClient(sessionId).query(SENT_MESSAGES_QUERY, { type: "sent", }); - - return res; -}; - -export async function SentMessages() { - const result = await getMessages(); const messages = result?.data?.messages; return ( diff --git a/apps/www/src/app/login/loading.tsx b/apps/www/src/app/login/loading.tsx index f095a2f8..5ba9d85f 100644 --- a/apps/www/src/app/login/loading.tsx +++ b/apps/www/src/app/login/loading.tsx @@ -2,7 +2,7 @@ import { Skeleton } from "@umamin/ui/components/skeleton"; export default function Loading() { return ( -
+
@@ -23,6 +23,6 @@ export default function Loading() {
-
+ ); } diff --git a/apps/www/src/app/notes/components/form.tsx b/apps/www/src/app/notes/components/form.tsx index d9d32f70..88c5a8b4 100644 --- a/apps/www/src/app/notes/components/form.tsx +++ b/apps/www/src/app/notes/components/form.tsx @@ -2,7 +2,6 @@ import { toast } from "sonner"; import { graphql } from "gql.tada"; -import { useRouter } from "next/navigation"; import { logEvent } from "firebase/analytics"; import { Loader2, Sparkles } from "lucide-react"; import { FormEventHandler, useState } from "react"; @@ -13,11 +12,12 @@ import { CurrentNoteQueryResult } from "../queries"; import { formatError } from "@/lib/utils"; import { analytics } from "@/lib/firebase"; +import { SelectUser } from "@umamin/db/schema/user"; +import { useNoteStore } from "@/store/useNoteStore"; import { Label } from "@umamin/ui/components/label"; import { Button } from "@umamin/ui/components/button"; import { Switch } from "@umamin/ui/components/switch"; import { Textarea } from "@umamin/ui/components/textarea"; -import { SelectUser } from "@umamin/db/schema/user"; const UPDATE_NOTE_MUTATION = graphql(` mutation UpdateNote($content: String!, $isAnonymous: Boolean!) { @@ -42,17 +42,16 @@ type Props = { }; export function NoteForm({ user, currentNote }: Props) { - const router = useRouter(); const [content, setContent] = useState(""); const [isFetching, setIsFetching] = useState(false); const [isAnonymous, setIsAnonymous] = useState(false); - const [noteContent, setNoteContent] = useState(currentNote?.content); - const [anonymous, setAnonymous] = useState(currentNote?.isAnonymous ?? false); - const [updatedAt, setUpdatedAt] = useState(currentNote?.updatedAt); + const updatedNote = useNoteStore((state) => state.note); + const clearNote = useNoteStore((state) => state.clear); + const updateNote = useNoteStore((state) => state.update); + const isCleared = useNoteStore((state) => state.isCleared); const onClearNote = async () => { - if (!currentNote) return; setIsFetching(true); const res = await client.mutation(DELETE_NOTE_MUTATION, {}); @@ -63,7 +62,7 @@ export function NoteForm({ user, currentNote }: Props) { return; } - setNoteContent(""); + clearNote(); toast.success("Note cleared"); setIsFetching(false); @@ -92,16 +91,12 @@ export function NoteForm({ user, currentNote }: Props) { } if (res.data) { - setNoteContent(content); setContent(""); - setUpdatedAt(res?.data?.updateNote?.updatedAt); - setAnonymous(res.data.updateNote.isAnonymous); + updateNote(res.data.updateNote); toast.success("Note updated"); } setIsFetching(false); - router.refresh(); - logEvent(analytics, "update_note"); }; @@ -116,7 +111,7 @@ export function NoteForm({ user, currentNote }: Props) { required value={content} onChange={(e) => setContent(e.target.value)} - maxLength={1000} + maxLength={500} placeholder="How's your day going?" className="focus-visible:ring-transparent text-base max-h-[500px]" autoComplete="off" @@ -151,16 +146,26 @@ export function NoteForm({ user, currentNote }: Props) { - {noteContent && ( + {currentNote && !isCleared && (

Your note

+
+ )} + + {!currentNote && updatedNote && ( +
+

Your note

+ { - const res = await getClient().query(NOTES_QUERY, {}); - return res; -}); - -const getCurrentNote = cache(async () => { +const getCurrentNote = async () => { const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? ""; if (!sessionId) { return null; @@ -58,11 +52,11 @@ const getCurrentNote = cache(async () => { const res = await getClient(sessionId).query(CURRENT_NOTE_QUERY, {}); return res; -}); +}; export default async function Page() { const { user } = await getSession(); - const notesResult = await getNotes(); + const notesResult = await getClient().query(NOTES_QUERY, {}); const currentNoteResult = await getCurrentNote(); const notes = notesResult.data?.notes; diff --git a/apps/www/src/app/register/loading.tsx b/apps/www/src/app/register/loading.tsx index c7de0d66..d623ffdd 100644 --- a/apps/www/src/app/register/loading.tsx +++ b/apps/www/src/app/register/loading.tsx @@ -2,33 +2,43 @@ import { Skeleton } from "@umamin/ui/components/skeleton"; export default function Loading() { return ( -
-
- - -
+
+
+
+ -
- - - -
+
+ + +
+
-
- - -
+
+
+ + +
-
- - +
+ + +
+
-
- - - +
+
+
+ + +
+ + +
+ + +
-
+
); } diff --git a/apps/www/src/app/settings/page.tsx b/apps/www/src/app/settings/page.tsx index 11921917..1b0e0f25 100644 --- a/apps/www/src/app/settings/page.tsx +++ b/apps/www/src/app/settings/page.tsx @@ -1,4 +1,3 @@ -import { cache } from "react"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; @@ -42,17 +41,6 @@ export const metadata = { }, }; -const getCurrentUser = cache(async () => { - const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? ""; - if (!sessionId) { - return null; - } - - const res = await getClient(sessionId).query(CURRENT_USER_QUERY, {}); - - return res; -}); - export default async function Settings() { const { user } = await getSession(); @@ -60,7 +48,8 @@ export default async function Settings() { redirect("/login"); } - const result = await getCurrentUser(); + const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? ""; + const result = await getClient(sessionId).query(CURRENT_USER_QUERY, {}); const userData = result?.data?.user; const tabsData = [ diff --git a/apps/www/src/app/to/[username]/components/chat-form.tsx b/apps/www/src/app/to/[username]/components/chat-form.tsx index 8d92e6c6..3cf8c355 100644 --- a/apps/www/src/app/to/[username]/components/chat-form.tsx +++ b/apps/www/src/app/to/[username]/components/chat-form.tsx @@ -7,6 +7,7 @@ import { analytics } from "@/lib/firebase"; import { Loader2, Send } from "lucide-react"; import { logEvent } from "firebase/analytics"; +import { cn } from "@umamin/ui/lib/utils"; import { formatError } from "@/lib/utils"; import { client } from "@/lib/gql/client"; import { Input } from "@umamin/ui/components/input"; @@ -76,7 +77,9 @@ export function ChatForm({ currentUserId, user }: Props) { } return ( -
+
setContent(e.target.value)} placeholder="Type your message..." diff --git a/apps/www/src/app/to/[username]/page.tsx b/apps/www/src/app/to/[username]/page.tsx index 8daf7633..bf6ccfe5 100644 --- a/apps/www/src/app/to/[username]/page.tsx +++ b/apps/www/src/app/to/[username]/page.tsx @@ -101,7 +101,7 @@ export default async function SendMessage({

@{user.username}

*/} - + umamin diff --git a/apps/www/src/store/useNoteStore.tsx b/apps/www/src/store/useNoteStore.tsx new file mode 100644 index 00000000..a980d617 --- /dev/null +++ b/apps/www/src/store/useNoteStore.tsx @@ -0,0 +1,28 @@ +import { create } from "zustand"; +import { CurrentNoteQueryResult } from "@/app/notes/queries"; + +type NoteData = Partial | null; + +type State = { + note: NoteData; + isCleared: boolean; +}; + +type Action = { + // eslint-disable-next-line no-unused-vars + update: (data: NoteData) => void; + clear: () => void; +}; + +export const useNoteStore = create((set) => ({ + note: null, + isCleared: false, + + clear: () => set({ note: null, isCleared: true }), + + update: (data) => + set({ + note: data, + isCleared: false, + }), +})); diff --git a/packages/gql/package.json b/packages/gql/package.json index 79bcd941..b3d81a1f 100644 --- a/packages/gql/package.json +++ b/packages/gql/package.json @@ -18,12 +18,14 @@ "@pothos/core": "^3.41.2", "@pothos/plugin-directives": "^3.10.3", "@pothos/plugin-scope-auth": "^3.22.1", + "@pothos/plugin-validation": "^3.10.2", "@umamin/aes": "workspace:*", "@umamin/db": "workspace:*", "graphql": "^16.8.1", "graphql-rate-limit-directive": "^2.0.5", "graphql-scalars": "^1.23.0", "nanoid": "^5.0.7", - "rate-limiter-flexible": "^5.0.3" + "rate-limiter-flexible": "^5.0.3", + "zod": "^3.23.8" } } diff --git a/packages/gql/src/builder.ts b/packages/gql/src/builder.ts index f443f0e7..2eea5c10 100644 --- a/packages/gql/src/builder.ts +++ b/packages/gql/src/builder.ts @@ -1,6 +1,7 @@ import SchemaBuilder from "@pothos/core"; import ScopeAuthPlugin from "@pothos/plugin-scope-auth"; import DirectivePlugin from "@pothos/plugin-directives"; +import ValidationPlugin from "@pothos/plugin-validation"; import { DateResolver, JSONResolver } from "graphql-scalars"; import { SelectNote } from "@umamin/db/schema/note"; @@ -56,7 +57,7 @@ const builder = new SchemaBuilder<{ }; }; }>({ - plugins: [ScopeAuthPlugin, DirectivePlugin], + plugins: [ScopeAuthPlugin, DirectivePlugin, ValidationPlugin], authScopes: async (ctx) => ({ authenticated: !!ctx.userId, }), diff --git a/packages/gql/src/models/message/types.ts b/packages/gql/src/models/message/types.ts index 0a80518b..ceaeda94 100644 --- a/packages/gql/src/models/message/types.ts +++ b/packages/gql/src/models/message/types.ts @@ -33,8 +33,14 @@ builder.objectType("MessagesWithCursor", { export const CreateMessageInput = builder.inputType("CreateMessageInput", { fields: (t) => ({ - question: t.string({ required: true }), - content: t.string({ required: true }), + question: t.string({ + required: true, + validate: { minLength: 1, maxLength: 150 }, + }), + content: t.string({ + required: true, + validate: { minLength: 1, maxLength: 500 }, + }), senderId: t.string(), receiverId: t.string({ required: true }), }), diff --git a/packages/gql/src/models/note/resolvers.ts b/packages/gql/src/models/note/resolvers.ts index 0433bc91..dc126608 100644 --- a/packages/gql/src/models/note/resolvers.ts +++ b/packages/gql/src/models/note/resolvers.ts @@ -110,7 +110,6 @@ builder.queryFields((t) => ({ } }, }), - })); builder.mutationFields((t) => ({ @@ -123,7 +122,10 @@ builder.mutationFields((t) => ({ rateLimit: { limit: 3, duration: 20 }, }, args: { - content: t.arg.string({ required: true }), + content: t.arg.string({ + required: true, + validate: { minLength: 1, maxLength: 500 }, + }), isAnonymous: t.arg.boolean({ required: true }), }, resolve: async (_, { content, isAnonymous }, ctx) => { diff --git a/packages/gql/src/models/user/types.ts b/packages/gql/src/models/user/types.ts index 73657c90..b635de68 100644 --- a/packages/gql/src/models/user/types.ts +++ b/packages/gql/src/models/user/types.ts @@ -45,9 +45,15 @@ addCommonFields([UserObject, PublicUserObject]); export const UpdateUserInput = builder.inputType("UpdateUserInput", { fields: (t) => ({ - username: t.string({ required: true }), - bio: t.string(), - question: t.string({ required: true }), - displayName: t.string({ required: true }), + username: t.string({ + required: true, + validate: { minLength: 5, maxLength: 20 }, + }), + bio: t.string({ validate: { maxLength: 150 } }), + question: t.string({ + required: true, + validate: { minLength: 1, maxLength: 150 }, + }), + displayName: t.string({ required: true, validate: { maxLength: 20 } }), }), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91075e2d..638a520a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,6 +377,9 @@ importers: '@pothos/plugin-scope-auth': specifier: ^3.22.1 version: 3.22.1(@pothos/core@3.41.2(graphql@16.8.1))(graphql@16.8.1) + '@pothos/plugin-validation': + specifier: ^3.10.2 + version: 3.10.2(@pothos/core@3.41.2(graphql@16.8.1))(graphql@16.8.1)(zod@3.23.8) '@umamin/aes': specifier: workspace:* version: link:../aes @@ -398,6 +401,9 @@ importers: rate-limiter-flexible: specifier: ^5.0.3 version: 5.0.3 + zod: + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@umamin/tsconfig': specifier: workspace:* @@ -1858,6 +1864,13 @@ packages: '@pothos/core': '*' graphql: '>=15.1.0' + '@pothos/plugin-validation@3.10.2': + resolution: {integrity: sha512-oA1z2NXYNozol47+opKiNzKc7+lfMKQeyu2oQd8ijQyRv+cMLpwx1kkd/E8gkt63XUpBWPmE7IZDJ6/Xyrfs3g==} + peerDependencies: + '@pothos/core': '*' + graphql: ^16.8.1 + zod: '*' + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -6751,6 +6764,12 @@ snapshots: '@pothos/core': 3.41.2(graphql@16.8.1) graphql: 16.8.1 + '@pothos/plugin-validation@3.10.2(@pothos/core@3.41.2(graphql@16.8.1))(graphql@16.8.1)(zod@3.23.8)': + dependencies: + '@pothos/core': 3.41.2(graphql@16.8.1) + graphql: 16.8.1 + zod: 3.23.8 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -8432,7 +8451,7 @@ snapshots: eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0) eslint-plugin-react: 7.34.2(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0) @@ -8503,7 +8522,7 @@ snapshots: enhanced-resolve: 5.17.0 eslint: 8.57.0 eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -8616,7 +8635,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5