From b25b705dacdaf6131db713129945669a3241de0b Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 7 Aug 2024 00:43:32 +0900 Subject: [PATCH 01/21] add: 'Sign in with Google' --- src/app/page.tsx | 20 +++++++++++++++++--- src/auth.ts | 14 ++++++++++++++ src/db/adapter-pg.ts | 18 +++++++++++++----- src/types/next-auth.d.ts | 1 + 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 16facf5..20430e7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,7 +22,7 @@ const SignInButton = ({ service, text, }: { - service: 'discord' | 'github'; + service: 'discord' | 'github' | 'google'; text: string; }) => (

Welcome {name}

+ {!isJoinedGuild ? ( ) : !githubUserID ? ( @@ -75,10 +81,18 @@ export default async function Home() { ) : ( <> )} + + {isJoinedGuild && !googleUserID && ( + + )} + {githubUserID && ( )} + {googleUserID && ( + + )} { 'use server'; diff --git a/src/auth.ts b/src/auth.ts index 207700a..b98e19c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,3 +1,4 @@ +import Google from '@auth/core/providers/google'; import type { Account, Profile, TokenSet } from '@auth/core/types'; import { Pool } from '@neondatabase/serverless'; import NextAuth, { type NextAuthConfig } from 'next-auth'; @@ -69,6 +70,11 @@ const updateAdapterUser = async ({ githubUserName: githubUserName, isJoinedOrganization: isJoinedOrganization, }); + } else if (account?.provider === 'google') { + await adapter.updateUser({ + ...adapterUser, + googleUserID: account.providerAccountId, + }); } } }; @@ -152,6 +158,14 @@ export const config = async (request: NextRequest | undefined) => { profile: getDiscordProfile(adapterUser), }), GitHub, + Google({ + authorization: { + params: { + // https://github.com/nextauthjs/next-auth/blob/748c9ecb8ce10bef2b628520451f676db0499f9d/docs/pages/guides/configuring-oauth-providers.mdx + scope: 'openid https://www.googleapis.com/auth/calendar', + }, + }, + }), ], theme: { logo: '/icon.png' }, } satisfies NextAuthConfig; diff --git a/src/db/adapter-pg.ts b/src/db/adapter-pg.ts index 3173a69..cb9449f 100644 --- a/src/db/adapter-pg.ts +++ b/src/db/adapter-pg.ts @@ -25,6 +25,7 @@ export default function PostgresAdapter(client: Pool): Adapter { githubUserID, githubUserName, discordUserID, + googleUserID, } = user; const sql = ` INSERT INTO users @@ -37,9 +38,10 @@ export default function PostgresAdapter(client: Pool): Adapter { "isJoinedOrganization", "githubUserID", "githubUserName", - "discordUserID" + "discordUserID", + "googleUserID" ) - VALUES ($1, $2, $3, $4 , $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4 , $5, $6, $7, $8, $9, $10) RETURNING id, name, @@ -50,7 +52,8 @@ export default function PostgresAdapter(client: Pool): Adapter { "isJoinedOrganization", "githubUserID", "githubUserName", - "discordUserID" + "discordUserID", + "googleUserID" `; const result = await client.query(sql, [ name, @@ -62,6 +65,7 @@ export default function PostgresAdapter(client: Pool): Adapter { githubUserID, githubUserName, discordUserID, + googleUserID, ]); return result.rows[0]; }, @@ -86,6 +90,7 @@ export default function PostgresAdapter(client: Pool): Adapter { githubUserID, githubUserName, discordUserID, + googleUserID, } = newUser; const updateSql = ` @@ -98,7 +103,8 @@ export default function PostgresAdapter(client: Pool): Adapter { "isJoinedOrganization" = $7, "githubUserID" = $8, "githubUserName" = $9, - "discordUserID" = $10 + "discordUserID" = $10, + "googleUserID" = $11 where id = $1 RETURNING name, @@ -110,7 +116,8 @@ export default function PostgresAdapter(client: Pool): Adapter { "isJoinedOrganization", "githubUserID", "githubUserName", - "discordUserID" + "discordUserID", + "googleUserID" `; const query2 = await client.query(updateSql, [ id, @@ -123,6 +130,7 @@ export default function PostgresAdapter(client: Pool): Adapter { githubUserID, githubUserName, discordUserID, + googleUserID, ]); return query2.rows[0]; }, diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 1e99ab1..b672ac6 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -8,6 +8,7 @@ declare module 'next-auth' { githubUserID?: number; githubUserName?: string; discordUserID?: string; + googleUserID?: string; } } From 25c8eb01f04b87871e88cbb850d79e6fd9e711d9 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 7 Aug 2024 01:05:52 +0900 Subject: [PATCH 02/21] feat: Persistent refresh token --- src/auth.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/auth.ts b/src/auth.ts index b98e19c..ad89f1a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -163,6 +163,10 @@ export const config = async (request: NextRequest | undefined) => { params: { // https://github.com/nextauthjs/next-auth/blob/748c9ecb8ce10bef2b628520451f676db0499f9d/docs/pages/guides/configuring-oauth-providers.mdx scope: 'openid https://www.googleapis.com/auth/calendar', + // https://next-auth.js.org/providers/google + prompt: 'consent', + access_type: 'offline', + response_type: 'code', }, }, }), From 649400c2a9440efa36eeecc57bb2d55ff31f0f84 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 8 Aug 2024 01:06:07 +0900 Subject: [PATCH 03/21] feat: override linkAccount to update when account exists --- src/auth.ts | 11 +++++- src/db/adapter-pg.ts | 87 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/auth.ts b/src/auth.ts index ad89f1a..e484c16 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,7 +2,11 @@ import Google from '@auth/core/providers/google'; import type { Account, Profile, TokenSet } from '@auth/core/types'; import { Pool } from '@neondatabase/serverless'; import NextAuth, { type NextAuthConfig } from 'next-auth'; -import type { Adapter, AdapterUser } from 'next-auth/adapters'; +import type { + Adapter, + AdapterAccountType, + AdapterUser, +} from 'next-auth/adapters'; import Discord, { type DiscordProfile } from 'next-auth/providers/discord'; import GitHub, { type GitHubProfile } from 'next-auth/providers/github'; import type { NextRequest } from 'next/server'; @@ -71,6 +75,11 @@ const updateAdapterUser = async ({ isJoinedOrganization: isJoinedOrganization, }); } else if (account?.provider === 'google') { + adapter.linkAccount?.({ + ...account, + type: account.type as AdapterAccountType, + userId: adapterUser.id, + }); await adapter.updateUser({ ...adapterUser, googleUserID: account.providerAccountId, diff --git a/src/db/adapter-pg.ts b/src/db/adapter-pg.ts index cb9449f..9dce3da 100644 --- a/src/db/adapter-pg.ts +++ b/src/db/adapter-pg.ts @@ -134,5 +134,92 @@ export default function PostgresAdapter(client: Pool): Adapter { ]); return query2.rows[0]; }, + async linkAccount(account) { + const exists = await client.query( + `SELECT EXISTS ( + SELECT 1 + FROM accounts + WHERE "userId" = $1 AND provider = $2 AND "providerAccountId" = $3 + );`, + [account.userId, account.provider, account.providerAccountId], + ); + + let sql: string; + if (exists.rows[0].exists) { + sql = ` + update accounts set + type = $3, + access_token = $5, + expires_at = $6, + refresh_token = $7, + id_token = $8, + scope = $9, + session_state = $10, + token_type = $11 + where "userId" = $1 AND provider = $2 AND "providerAccountId" = $4 + returning + id, + "userId", + provider, + type, + "providerAccountId", + access_token, + expires_at, + refresh_token, + id_token, + scope, + session_state, + token_type + `; + } else { + sql = ` + insert into accounts + ( + "userId", + provider, + type, + "providerAccountId", + access_token, + expires_at, + refresh_token, + id_token, + scope, + session_state, + token_type + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + returning + id, + "userId", + provider, + type, + "providerAccountId", + access_token, + expires_at, + refresh_token, + id_token, + scope, + session_state, + token_type + `; + } + + const params = [ + account.userId, + account.provider, + account.type, + account.providerAccountId, + account.access_token, + account.expires_at, + account.refresh_token, + account.id_token, + account.scope, + account.session_state, + account.token_type, + ]; + + const result = await client.query(sql, params); + return mapExpiresAt(result.rows[0]); + }, }; } From cc79a8d1f6d032693bd79724d34459318b524072 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 8 Aug 2024 16:17:10 +0900 Subject: [PATCH 04/21] chore: Add Google authentication credentials to .env.local.sample --- .env.local.sample | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.local.sample b/.env.local.sample index d7cfee7..875e355 100644 --- a/.env.local.sample +++ b/.env.local.sample @@ -6,6 +6,9 @@ AUTH_DISCORD_SECRET= AUTH_GITHUB_ID= AUTH_GITHUB_SECRET= +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= + POSTGRES_URL= AUDIT_LOG_WEBHOOK= From b08af05ffd7800fda872cb16000875168b17001e Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 8 Aug 2024 16:18:16 +0900 Subject: [PATCH 05/21] feat: Add Google Calendar integration for Google sign-in users --- src/app/page.tsx | 24 ++++++++++++++++++++- src/db/adapter-pg.ts | 18 +++++++++++----- src/types/next-auth.d.ts | 1 + src/utils/calender.ts | 46 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 src/utils/calender.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index 20430e7..66d9f91 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ import { auth, signIn, signOut } from '@/auth'; +import { linkCalendar, unlinkCalendar } from '@/utils/calender'; import { createOrganizationInvitation } from '@/utils/github'; const ButtonInForm = ({ @@ -59,6 +60,7 @@ export default async function Home() { githubUserID, googleUserID, isJoinedOrganization, + isLinkedToCalendar: isLinkedToCalender, name, } = session.user || {}; @@ -91,8 +93,28 @@ export default async function Home() { )} {googleUserID && ( - + <> + + {!isLinkedToCalender ? ( + { + 'use server'; + await linkCalendar(); + }} + text='Link to Google Calendar' + /> + ) : ( + { + 'use server'; + await unlinkCalendar(); + }} + text='Unlink from Google Calendar' + /> + )} + )} + { 'use server'; diff --git a/src/db/adapter-pg.ts b/src/db/adapter-pg.ts index 9dce3da..497fb2c 100644 --- a/src/db/adapter-pg.ts +++ b/src/db/adapter-pg.ts @@ -22,6 +22,7 @@ export default function PostgresAdapter(client: Pool): Adapter { image, isJoinedGuild, isJoinedOrganization, + isLinkedToCalendar, githubUserID, githubUserName, discordUserID, @@ -36,12 +37,13 @@ export default function PostgresAdapter(client: Pool): Adapter { image, "isJoinedGuild", "isJoinedOrganization", + "isLinkedToCalendar", "githubUserID", "githubUserName", "discordUserID", "googleUserID" ) - VALUES ($1, $2, $3, $4 , $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4 , $5, $6, $7, $8, $9, $10, $11) RETURNING id, name, @@ -50,6 +52,7 @@ export default function PostgresAdapter(client: Pool): Adapter { image, "isJoinedGuild", "isJoinedOrganization", + "isLinkedToCalendar", "githubUserID", "githubUserName", "discordUserID", @@ -62,6 +65,7 @@ export default function PostgresAdapter(client: Pool): Adapter { image, isJoinedGuild, isJoinedOrganization, + isLinkedToCalendar, githubUserID, githubUserName, discordUserID, @@ -87,6 +91,7 @@ export default function PostgresAdapter(client: Pool): Adapter { image, isJoinedGuild, isJoinedOrganization, + isLinkedToCalendar, githubUserID, githubUserName, discordUserID, @@ -101,10 +106,11 @@ export default function PostgresAdapter(client: Pool): Adapter { image = $5, "isJoinedGuild" = $6, "isJoinedOrganization" = $7, - "githubUserID" = $8, - "githubUserName" = $9, - "discordUserID" = $10, - "googleUserID" = $11 + "isLinkedToCalendar" = $8, + "githubUserID" = $9, + "githubUserName" = $10, + "discordUserID" = $11, + "googleUserID" = $12 where id = $1 RETURNING name, @@ -114,6 +120,7 @@ export default function PostgresAdapter(client: Pool): Adapter { image, "isJoinedGuild", "isJoinedOrganization", + "isLinkedToCalendar", "githubUserID", "githubUserName", "discordUserID", @@ -127,6 +134,7 @@ export default function PostgresAdapter(client: Pool): Adapter { image, isJoinedGuild, isJoinedOrganization, + isLinkedToCalendar, githubUserID, githubUserName, discordUserID, diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index b672ac6..a2b6d8f 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -5,6 +5,7 @@ declare module 'next-auth' { interface User { isJoinedGuild?: boolean; isJoinedOrganization?: boolean; + isLinkedToCalendar?: boolean; githubUserID?: number; githubUserName?: string; discordUserID?: string; diff --git a/src/utils/calender.ts b/src/utils/calender.ts new file mode 100644 index 0000000..9e6f79f --- /dev/null +++ b/src/utils/calender.ts @@ -0,0 +1,46 @@ +import { auth } from '@/auth'; +import { Pool } from '@neondatabase/serverless'; + +export async function linkCalendar() { + const session = await auth(); + const user = session?.user; + if (!user || user.isLinkedToCalendar) { + return; + } + + const pool = new Pool({ + connectionString: process.env.POSTGRES_URL, + }); + + pool.query( + ` + UPDATE users SET + "isLinkedToCalendar" = true + WHERE + id = $1 + `, + [user.id], + ); +} + +export async function unlinkCalendar() { + const session = await auth(); + const user = session?.user; + if (!user || !user.isLinkedToCalendar) { + return; + } + + const pool = new Pool({ + connectionString: process.env.POSTGRES_URL, + }); + + pool.query( + ` + UPDATE users SET + "isLinkedToCalendar" = false + WHERE + id = $1 + `, + [user.id], + ); +} From a3683843337c06c8e409d8ae4870cfa03c4ac9b3 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 8 Aug 2024 16:21:49 +0900 Subject: [PATCH 06/21] fix: typo --- src/app/page.tsx | 6 +++--- src/utils/{calender.ts => calendar.ts} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename src/utils/{calender.ts => calendar.ts} (100%) diff --git a/src/app/page.tsx b/src/app/page.tsx index 66d9f91..be569f6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ import { auth, signIn, signOut } from '@/auth'; -import { linkCalendar, unlinkCalendar } from '@/utils/calender'; +import { linkCalendar, unlinkCalendar } from '@/utils/calendar'; import { createOrganizationInvitation } from '@/utils/github'; const ButtonInForm = ({ @@ -60,7 +60,7 @@ export default async function Home() { githubUserID, googleUserID, isJoinedOrganization, - isLinkedToCalendar: isLinkedToCalender, + isLinkedToCalendar, name, } = session.user || {}; @@ -95,7 +95,7 @@ export default async function Home() { {googleUserID && ( <> - {!isLinkedToCalender ? ( + {!isLinkedToCalendar ? ( { 'use server'; diff --git a/src/utils/calender.ts b/src/utils/calendar.ts similarity index 100% rename from src/utils/calender.ts rename to src/utils/calendar.ts From 4cb3ec2b890303dffe439d803dac2cc37652faed Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 8 Aug 2024 16:59:53 +0900 Subject: [PATCH 07/21] feat: Update linkCalendar and unlinkCalendar functions to use async/await --- src/utils/calendar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/calendar.ts b/src/utils/calendar.ts index 9e6f79f..d3da286 100644 --- a/src/utils/calendar.ts +++ b/src/utils/calendar.ts @@ -12,7 +12,7 @@ export async function linkCalendar() { connectionString: process.env.POSTGRES_URL, }); - pool.query( + await pool.query( ` UPDATE users SET "isLinkedToCalendar" = true @@ -34,7 +34,7 @@ export async function unlinkCalendar() { connectionString: process.env.POSTGRES_URL, }); - pool.query( + await pool.query( ` UPDATE users SET "isLinkedToCalendar" = false From 02a918a17a1effbeead69f56f2fc9c3d9d960fa8 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Thu, 8 Aug 2024 17:09:16 +0900 Subject: [PATCH 08/21] fix: Add redirect after linking/unlinking calendar --- src/utils/calendar.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/utils/calendar.ts b/src/utils/calendar.ts index d3da286..7d4672d 100644 --- a/src/utils/calendar.ts +++ b/src/utils/calendar.ts @@ -1,5 +1,6 @@ import { auth } from '@/auth'; import { Pool } from '@neondatabase/serverless'; +import { redirect } from 'next/navigation'; export async function linkCalendar() { const session = await auth(); @@ -21,6 +22,7 @@ export async function linkCalendar() { `, [user.id], ); + redirect('/'); } export async function unlinkCalendar() { @@ -43,4 +45,5 @@ export async function unlinkCalendar() { `, [user.id], ); + redirect('/'); } From 4863e6ea80ab99392ad20debb6eb27b418b22b20 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 9 Aug 2024 16:03:38 +0900 Subject: [PATCH 09/21] feat: introduce vercel/postgres --- package.json | 1 + pnpm-lock.yaml | 53 +++++++++++++++++++++++++++++++++++++++++++ src/utils/calendar.ts | 28 ++++++----------------- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 9d29b07..0f4c817 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@auth/core": "^0.34.2", "@auth/pg-adapter": "^1.4.2", "@neondatabase/serverless": "^0.9.4", + "@vercel/postgres": "^0.9.0", "next": "14.2.5", "next-auth": "5.0.0-beta.20", "react": "^18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45cc66b..811eea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@neondatabase/serverless': specifier: ^0.9.4 version: 0.9.4 + '@vercel/postgres': + specifier: ^0.9.0 + version: 0.9.0 next: specifier: 14.2.5 version: 14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -261,6 +264,10 @@ packages: '@types/react@18.3.3': resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} + '@vercel/postgres@0.9.0': + resolution: {integrity: sha512-WiI2g3+ce2g1u1gP41MoDj2DsMuQQ+us7vHobysRixKECGaLHpfTI7DuVZmHU087ozRAGr3GocSyqmWLLo+fig==} + engines: {node: '>=14.6'} + ansi-escapes@7.0.0: resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} engines: {node: '>=18'} @@ -305,6 +312,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bufferutil@4.0.8: + resolution: {integrity: sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==} + engines: {node: '>=6.14.2'} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -623,6 +634,10 @@ packages: sass: optional: true + node-gyp-build@4.8.1: + resolution: {integrity: sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -973,6 +988,10 @@ packages: undici-types@6.13.0: resolution: {integrity: sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==} + utf-8-validate@6.0.4: + resolution: {integrity: sha512-xu9GQDeFp+eZ6LnCywXN/zBancWvOpUMzgjLPSjy4BRHSmTelvn2E0DG0o1sTiw5hkCKBHo8rwSKncfRfv2EEQ==} + engines: {node: '>=6.14.2'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -993,6 +1012,18 @@ packages: resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} engines: {node: '>=18'} + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -1166,6 +1197,13 @@ snapshots: '@types/prop-types': 15.7.12 csstype: 3.1.3 + '@vercel/postgres@0.9.0': + dependencies: + '@neondatabase/serverless': 0.9.4 + bufferutil: 4.0.8 + utf-8-validate: 6.0.4 + ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + ansi-escapes@7.0.0: dependencies: environment: 1.1.0 @@ -1201,6 +1239,10 @@ snapshots: dependencies: fill-range: 7.1.1 + bufferutil@4.0.8: + dependencies: + node-gyp-build: 4.8.1 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -1490,6 +1532,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-gyp-build@4.8.1: {} + normalize-path@3.0.0: {} npm-run-path@5.3.0: @@ -1815,6 +1859,10 @@ snapshots: undici-types@6.13.0: {} + utf-8-validate@6.0.4: + dependencies: + node-gyp-build: 4.8.1 + util-deprecate@1.0.2: {} which@2.0.2: @@ -1839,6 +1887,11 @@ snapshots: string-width: 7.1.0 strip-ansi: 7.1.0 + ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4): + optionalDependencies: + bufferutil: 4.0.8 + utf-8-validate: 6.0.4 + xtend@4.0.2: {} yaml@2.5.0: {} diff --git a/src/utils/calendar.ts b/src/utils/calendar.ts index 7d4672d..90f39f5 100644 --- a/src/utils/calendar.ts +++ b/src/utils/calendar.ts @@ -1,5 +1,5 @@ import { auth } from '@/auth'; -import { Pool } from '@neondatabase/serverless'; +import { sql } from '@vercel/postgres'; import { redirect } from 'next/navigation'; export async function linkCalendar() { @@ -9,19 +9,12 @@ export async function linkCalendar() { return; } - const pool = new Pool({ - connectionString: process.env.POSTGRES_URL, - }); - - await pool.query( - ` + await sql` UPDATE users SET "isLinkedToCalendar" = true WHERE - id = $1 - `, - [user.id], - ); + id = ${user.id}; + `; redirect('/'); } @@ -32,18 +25,11 @@ export async function unlinkCalendar() { return; } - const pool = new Pool({ - connectionString: process.env.POSTGRES_URL, - }); - - await pool.query( - ` + await sql` UPDATE users SET "isLinkedToCalendar" = false WHERE - id = $1 - `, - [user.id], - ); + id = ${user.id}; + `; redirect('/'); } From a8203cf44d95d92717567fcc5ed3ac86881b1b91 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 9 Aug 2024 18:16:00 +0900 Subject: [PATCH 10/21] feat: Add Discord API dependencies --- package.json | 2 ++ pnpm-lock.yaml | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/package.json b/package.json index 0f4c817..6ed7a19 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ "dependencies": { "@auth/core": "^0.34.2", "@auth/pg-adapter": "^1.4.2", + "@discordjs/rest": "^2.3.0", "@neondatabase/serverless": "^0.9.4", "@vercel/postgres": "^0.9.0", + "discord-api-types": "^0.37.93", "next": "14.2.5", "next-auth": "5.0.0-beta.20", "react": "^18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 811eea0..1a14463 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,18 @@ importers: '@auth/pg-adapter': specifier: ^1.4.2 version: 1.4.2(pg@8.12.0) + '@discordjs/rest': + specifier: ^2.3.0 + version: 2.3.0 '@neondatabase/serverless': specifier: ^0.9.4 version: 0.9.4 '@vercel/postgres': specifier: ^0.9.0 version: 0.9.0 + discord-api-types: + specifier: ^0.37.93 + version: 0.37.93 next: specifier: 14.2.5 version: 14.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -139,6 +145,18 @@ packages: cpu: [x64] os: [win32] + '@discordjs/collection@2.1.0': + resolution: {integrity: sha512-mLcTACtXUuVgutoznkh6hS3UFqYirDYAg5Dc1m8xn6OvPjetnUlf/xjtqnnc47OwWdaoCQnHmHh9KofhD6uRqw==} + engines: {node: '>=18'} + + '@discordjs/rest@2.3.0': + resolution: {integrity: sha512-C1kAJK8aSYRv3ZwMG8cvrrW4GN0g5eMdP8AuN8ODH5DyOCbHgJspze1my3xHOAgwLJdKUbWNVyAeJ9cEdduqIg==} + engines: {node: '>=16.11.0'} + + '@discordjs/util@1.1.0': + resolution: {integrity: sha512-IndcI5hzlNZ7GS96RV3Xw1R2kaDuXEp7tRIy/KlhidpN/BQ1qh1NZt3377dMLTa44xDUNKT7hnXkA/oUAzD/lg==} + engines: {node: '>=16.11.0'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -240,6 +258,14 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@sapphire/async-queue@1.5.3': + resolution: {integrity: sha512-x7zadcfJGxFka1Q3f8gCts1F0xMwCKbZweM85xECGI0hBTeIZJGGCrHgLggihBoprlQ/hBmDR5LKfIPqnmHM3w==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/snowflake@3.5.3': + resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -268,6 +294,10 @@ packages: resolution: {integrity: sha512-WiI2g3+ce2g1u1gP41MoDj2DsMuQQ+us7vHobysRixKECGaLHpfTI7DuVZmHU087ozRAGr3GocSyqmWLLo+fig==} engines: {node: '>=14.6'} + '@vladfrangu/async_event_emitter@2.4.5': + resolution: {integrity: sha512-J7T3gUr3Wz0l7Ni1f9upgBZ7+J22/Q1B7dl0X6fG+fTsD+H+31DIosMHj4Um1dWQwqbcQ3oQf+YS2foYkDc9cQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + ansi-escapes@7.0.0: resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} engines: {node: '>=18'} @@ -392,6 +422,12 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + discord-api-types@0.37.83: + resolution: {integrity: sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA==} + + discord-api-types@0.37.93: + resolution: {integrity: sha512-M5jn0x3bcXk8EI2c6F6V6LeOWq10B/cJf+YJSyqNmg7z4bdXK+Z7g9zGJwHS0h9Bfgs0nun2LQISFOzwck7G9A==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -562,6 +598,9 @@ packages: resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} engines: {node: 14 || >=16.14} + magic-bytes.js@1.10.0: + resolution: {integrity: sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -988,6 +1027,10 @@ packages: undici-types@6.13.0: resolution: {integrity: sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==} + undici@6.13.0: + resolution: {integrity: sha512-Q2rtqmZWrbP8nePMq7mOJIN98M0fYvSgV89vwl/BQRT4mDOeY2GXZngfGpcBBhtky3woM7G24wZV3Q304Bv6cw==} + engines: {node: '>=18.0'} + utf-8-validate@6.0.4: resolution: {integrity: sha512-xu9GQDeFp+eZ6LnCywXN/zBancWvOpUMzgjLPSjy4BRHSmTelvn2E0DG0o1sTiw5hkCKBHo8rwSKncfRfv2EEQ==} engines: {node: '>=6.14.2'} @@ -1091,6 +1134,22 @@ snapshots: '@biomejs/cli-win32-x64@1.8.3': optional: true + '@discordjs/collection@2.1.0': {} + + '@discordjs/rest@2.3.0': + dependencies: + '@discordjs/collection': 2.1.0 + '@discordjs/util': 1.1.0 + '@sapphire/async-queue': 1.5.3 + '@sapphire/snowflake': 3.5.3 + '@vladfrangu/async_event_emitter': 2.4.5 + discord-api-types: 0.37.83 + magic-bytes.js: 1.10.0 + tslib: 2.6.2 + undici: 6.13.0 + + '@discordjs/util@1.1.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -1167,6 +1226,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@sapphire/async-queue@1.5.3': {} + + '@sapphire/snowflake@3.5.3': {} + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.5': @@ -1204,6 +1267,8 @@ snapshots: utf-8-validate: 6.0.4 ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + '@vladfrangu/async_event_emitter@2.4.5': {} + ansi-escapes@7.0.0: dependencies: environment: 1.1.0 @@ -1306,6 +1371,10 @@ snapshots: didyoumean@1.2.2: {} + discord-api-types@0.37.83: {} + + discord-api-types@0.37.93: {} + dlv@1.1.3: {} eastasianwidth@0.2.0: {} @@ -1472,6 +1541,8 @@ snapshots: lru-cache@10.2.2: {} + magic-bytes.js@1.10.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -1859,6 +1930,8 @@ snapshots: undici-types@6.13.0: {} + undici@6.13.0: {} + utf-8-validate@6.0.4: dependencies: node-gyp-build: 4.8.1 From b610634ff85c3fff3d0e627141adfdac3d6a452c Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 9 Aug 2024 18:16:57 +0900 Subject: [PATCH 11/21] feat: Synchronize events when calendar link settings are changed. --- src/utils/calendar.ts | 35 ------- src/utils/calendar/calendarService.ts | 98 ++++++++++++++++++ src/utils/calendar/index.ts | 143 ++++++++++++++++++++++++++ src/utils/calendar/mapping.ts | 26 +++++ src/utils/calendar/recurrenceUtil.ts | 78 ++++++++++++++ src/utils/calendar/types.ts | 11 ++ 6 files changed, 356 insertions(+), 35 deletions(-) delete mode 100644 src/utils/calendar.ts create mode 100644 src/utils/calendar/calendarService.ts create mode 100644 src/utils/calendar/index.ts create mode 100644 src/utils/calendar/mapping.ts create mode 100644 src/utils/calendar/recurrenceUtil.ts create mode 100644 src/utils/calendar/types.ts diff --git a/src/utils/calendar.ts b/src/utils/calendar.ts deleted file mode 100644 index 90f39f5..0000000 --- a/src/utils/calendar.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { auth } from '@/auth'; -import { sql } from '@vercel/postgres'; -import { redirect } from 'next/navigation'; - -export async function linkCalendar() { - const session = await auth(); - const user = session?.user; - if (!user || user.isLinkedToCalendar) { - return; - } - - await sql` - UPDATE users SET - "isLinkedToCalendar" = true - WHERE - id = ${user.id}; - `; - redirect('/'); -} - -export async function unlinkCalendar() { - const session = await auth(); - const user = session?.user; - if (!user || !user.isLinkedToCalendar) { - return; - } - - await sql` - UPDATE users SET - "isLinkedToCalendar" = false - WHERE - id = ${user.id}; - `; - redirect('/'); -} diff --git a/src/utils/calendar/calendarService.ts b/src/utils/calendar/calendarService.ts new file mode 100644 index 0000000..d47d393 --- /dev/null +++ b/src/utils/calendar/calendarService.ts @@ -0,0 +1,98 @@ +// Google Calendarの操作用 + +import type { ScheduledEvent } from './types'; + +function createSchemaEvent(event: ScheduledEvent) { + // biome-ignore lint/suspicious/noExplicitAny: + const body: any = { + location: event.location, + id: event.id, + summary: event.name, + description: event.description, + start: { + dateTime: event.starttime.toISOString(), + timeZone: 'Asia/Tokyo', + }, + end: { + // starttimeの1時間後 + dateTime: new Date( + event.starttime.getTime() + 60 * 60 * 1000, + ).toISOString(), + timeZone: 'Asia/Tokyo', + }, + source: { + url: event.url ?? undefined, + title: event.name, + }, + }; + + if (event.recurrence) { + body.recurrence = [event.recurrence]; + } + + return body; +} + +export async function createCalEvent( + access_token: string, + event: ScheduledEvent, +) { + const body = createSchemaEvent(event); + + const res = await fetch( + 'https://www.googleapis.com/calendar/v3/calendars/primary/events', + { + method: 'POST', + headers: { + Authorization: `Bearer ${access_token}`, + }, + body: body, + }, + ); + + // すでに存在する場合, UIから削除した場合、キャンセル扱いになる + if (res.status === 409) { + await fetch( + `https://www.googleapis.com/calendar/v3/calendars/primary/events/${event.id}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${access_token}`, + }, + body: body, + }, + ); + } +} + +export async function updateCalEvent( + access_token: string, + event: ScheduledEvent, +) { + const body = createSchemaEvent(event); + await fetch( + `https://www.googleapis.com/calendar/v3/calendars/primary/events/${event.id}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${access_token}`, + }, + body: body, + }, + ); +} + +export async function removeCalEvent( + access_token: string, + event: Pick, +) { + await fetch( + `https://www.googleapis.com/calendar/v3/calendars/primary/events/${event.id}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${access_token}`, + }, + }, + ); +} diff --git a/src/utils/calendar/index.ts b/src/utils/calendar/index.ts new file mode 100644 index 0000000..bbdb2a2 --- /dev/null +++ b/src/utils/calendar/index.ts @@ -0,0 +1,143 @@ +import { auth } from '@/auth'; +import { REST } from '@discordjs/rest'; +import { sql } from '@vercel/postgres'; +import { type APIGuildScheduledEvent, Routes } from 'discord-api-types/v10'; +import { redirect } from 'next/navigation'; +import { createCalEvent, removeCalEvent } from './calendarService'; +import { transformAPIGuildScheduledEventToScheduledEvent } from './mapping'; + +async function updateUserToken(user: { + id: string; + access_token: string; + expires_at: number; +}) { + await sql` + UPDATE accounts SET + access_token = ${user.access_token}, + expires_at = ${user.expires_at} + WHERE "userId" = ${user.id} AND provider = 'google'; + `; +} + +async function retrieveUserToken( + id: string, +): Promise<{ + id: string; + refresh_token: string; + access_token: string; + expires_at: number; +} | null> { + const result = await sql<{ + id: string; + refresh_token: string; + access_token: string; + expires_at: number; + }>` + SELECT users.id, accounts.refresh_token, accounts.access_token, accounts.expires_at FROM users + JOIN accounts ON accounts."userId" = users.id + WHERE users.id = ${id} AND accounts.provider = 'google'; + `; + const userToken = result.rows[0]; + if (!userToken) { + return null; + } + if (userToken.expires_at < Math.floor(Date.now() / 1000) + 60) { + const json = await refreshAccessToken(userToken.refresh_token); + if (!json) { + return null; + } + + userToken.access_token = json.access_token; + userToken.expires_at = json.expires_at; + await updateUserToken(userToken); + } + + return userToken; +} + +async function refreshAccessToken(refreshToken: string) { + const res = await fetch('https://www.googleapis.com/oauth2/v4/token', { + method: 'POST', + body: new URLSearchParams({ + client_id: process.env.AUTH_GOOGLE_ID ?? '', + client_secret: process.env.AUTH_GOOGLE_SECRET ?? '', + refresh_token: refreshToken, + grant_type: 'refresh_token', + }), + }); + const json = await res.json(); + if ( + res.status !== 200 || + typeof json.access_token !== 'string' || + typeof json.expires_in !== 'number' + ) { + return null; + } + + return { + access_token: json.access_token, + expires_at: Math.floor(Date.now() / 1000) + json.expires_in, + }; +} + +async function updateIsLinkedToCalendar(id: string, value: boolean) { + await sql` + UPDATE users SET + "isLinkedToCalendar" = ${value} + WHERE + id = ${id}; + `; +} + +export async function linkCalendar() { + const session = await auth(); + const user = session?.user; + if (!user || !user.id || user.isLinkedToCalendar) { + return; + } + const userAndToken = await retrieveUserToken(user.id); + if (!userAndToken) { + return; + } + + await updateIsLinkedToCalendar(user.id, true); + + const rest = new REST({ version: '10' }).setToken( + process.env.DISCORD_BOT_TOKEN as string, + ); + const events = (await rest.get( + Routes.guildScheduledEvents(process.env.DISCORD_GUILD_ID as string), + )) as APIGuildScheduledEvent[]; + for (const event of events) { + createCalEvent( + userAndToken.access_token, + transformAPIGuildScheduledEventToScheduledEvent(event), + ); + } + redirect('/'); +} + +export async function unlinkCalendar() { + const session = await auth(); + const user = session?.user; + if (!user || !user.id || !user.isLinkedToCalendar) { + return; + } + const userAndToken = await retrieveUserToken(user.id); + if (!userAndToken) { + return; + } + + await updateIsLinkedToCalendar(user.id as string, false); + + const rest = new REST({ version: '10' }).setToken( + process.env.DISCORD_BOT_TOKEN as string, + ); + const events = (await rest.get( + Routes.guildScheduledEvents(process.env.DISCORD_GUILD_ID as string), + )) as APIGuildScheduledEvent[]; + for (const event of events) { + removeCalEvent(userAndToken.access_token, event); + } + redirect('/'); +} diff --git a/src/utils/calendar/mapping.ts b/src/utils/calendar/mapping.ts new file mode 100644 index 0000000..fb27804 --- /dev/null +++ b/src/utils/calendar/mapping.ts @@ -0,0 +1,26 @@ +import type { APIGuildScheduledEvent } from 'discord-api-types/v10'; +import { convertRFC5545RecurrenceRule } from './recurrenceUtil'; +import type { ScheduledEvent } from './types'; + +// APIGuildScheduledEvent -> ScheduledEvent +export function transformAPIGuildScheduledEventToScheduledEvent( + event: APIGuildScheduledEvent, +): ScheduledEvent { + return { + id: event.id, + name: event.name, + description: event.description ?? null, + starttime: new Date(event.scheduled_start_time), + endtime: event.scheduled_end_time + ? new Date(event.scheduled_end_time) + : null, + creatorid: event.creator_id ?? null, + location: event.entity_metadata?.location ?? null, + // biome-ignore lint/suspicious/noExplicitAny: + recurrence: (event as any).recurrence_rule + ? // biome-ignore lint/suspicious/noExplicitAny: + convertRFC5545RecurrenceRule((event as any).recurrence_rule) + : null, + url: `https://discord.com/events/${event.guild_id}/${event.id}`, + }; +} diff --git a/src/utils/calendar/recurrenceUtil.ts b/src/utils/calendar/recurrenceUtil.ts new file mode 100644 index 0000000..2da6973 --- /dev/null +++ b/src/utils/calendar/recurrenceUtil.ts @@ -0,0 +1,78 @@ +export type APIRecurrenceRuleFrequency = 0 | 1 | 2 | 3; +export type APIRecurrenceRuleWeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; +export type APIRecurrenceRuleMonth = + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12; + +export interface APIRecurrenceRule { + start: Date; + end?: Date | undefined | null; + frequency: APIRecurrenceRuleFrequency; + interval: number; + by_weekday?: APIRecurrenceRuleWeekDay[] | undefined | null; + by_n_weekday?: + | { n: 1 | 2 | 3 | 4 | 5; day: APIRecurrenceRuleWeekDay }[] + | undefined + | null; + by_month?: APIRecurrenceRuleMonth[] | undefined | null; + by_month_day?: number[] | undefined | null; + by_year_day?: number[] | undefined | null; + count?: number | undefined | null; +} + +export function getWeekdayString(weekday: APIRecurrenceRuleWeekDay): string { + // https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event-recurrence-rule-object-guild-scheduled-event-recurrence-rule-weekday + // 月曜始まり + return ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'][weekday]; +} + +export function getFrequencyString( + frequency: APIRecurrenceRuleFrequency, +): string { + return ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY'][frequency]; +} + +// https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10 +export function convertRFC5545RecurrenceRule( + rule: APIRecurrenceRule, +): string | undefined { + const { by_weekday, by_n_weekday, by_month, by_month_day, by_year_day } = + rule; + let str = `RRULE:FREQ=${getFrequencyString(rule.frequency)};INTERVAL=${rule.interval}`; + + if (by_weekday) { + str += `;BYDAY=${by_weekday.map((day) => getWeekdayString(day)).join(',')}`; + } + if (by_n_weekday) { + str += `;BYDAY=${by_n_weekday.map((day) => `${day.n}${getWeekdayString(day.day)}`).join(',')}`; + } + if (by_month) { + str += `;BYMONTH=${by_month.join(',')}`; + } + if (by_month_day) { + str += `;BYMONTHDAY=${by_month_day.join(',')}`; + } + if (by_year_day) { + str += `;BYYEARDAY=${by_year_day.join(',')}`; + } + + console.assert(!rule.end, 'end should be undefined.'); + if (rule.end) { + str += `;UNTIL=${rule.end.toISOString().replace(/[-:]/g, '')}`; + } + if (rule.count) { + str += `;COUNT=${rule.count}`; + } + + return str; +} diff --git a/src/utils/calendar/types.ts b/src/utils/calendar/types.ts new file mode 100644 index 0000000..8e51d47 --- /dev/null +++ b/src/utils/calendar/types.ts @@ -0,0 +1,11 @@ +export interface ScheduledEvent { + id: string; + name: string; + description?: string | undefined | null; + starttime: Date; + endtime?: Date | undefined | null; + creatorid?: string | undefined | null; + location?: string | undefined | null; + recurrence?: string | undefined | null; + url?: string | undefined | null; +} From ae2a9cbb60954ebc9985649f39facae48e6f568a Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 9 Aug 2024 18:19:09 +0900 Subject: [PATCH 12/21] refactor: index.ts --- src/utils/calendar/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/utils/calendar/index.ts b/src/utils/calendar/index.ts index bbdb2a2..bcff8f0 100644 --- a/src/utils/calendar/index.ts +++ b/src/utils/calendar/index.ts @@ -19,9 +19,7 @@ async function updateUserToken(user: { `; } -async function retrieveUserToken( - id: string, -): Promise<{ +async function retrieveUserToken(id: string): Promise<{ id: string; refresh_token: string; access_token: string; From 1f7a78b1b3a74fb14d93a758ab2614a7486edf24 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Fri, 9 Aug 2024 19:14:06 +0900 Subject: [PATCH 13/21] fix: recurrenceUtil.ts --- src/utils/calendar/recurrenceUtil.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils/calendar/recurrenceUtil.ts b/src/utils/calendar/recurrenceUtil.ts index 2da6973..db3f4df 100644 --- a/src/utils/calendar/recurrenceUtil.ts +++ b/src/utils/calendar/recurrenceUtil.ts @@ -66,9 +66,8 @@ export function convertRFC5545RecurrenceRule( str += `;BYYEARDAY=${by_year_day.join(',')}`; } - console.assert(!rule.end, 'end should be undefined.'); if (rule.end) { - str += `;UNTIL=${rule.end.toISOString().replace(/[-:]/g, '')}`; + str += `;UNTIL=${rule.end.toISOString().replace(/[-:]/g, '').split('.')[0]}Z`; } if (rule.count) { str += `;COUNT=${rule.count}`; From b1a2a11dd934f377f7090ae27b247826f55aa269 Mon Sep 17 00:00:00 2001 From: Yuto Terada <66758394+indigo-san@users.noreply.github.com> Date: Mon, 12 Aug 2024 03:54:09 +0900 Subject: [PATCH 14/21] Update src/utils/calendar/recurrenceUtil.ts Co-authored-by: Kawahara Shotaro <121674121+k-taro56@users.noreply.github.com> --- src/utils/calendar/recurrenceUtil.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/utils/calendar/recurrenceUtil.ts b/src/utils/calendar/recurrenceUtil.ts index db3f4df..f21ad10 100644 --- a/src/utils/calendar/recurrenceUtil.ts +++ b/src/utils/calendar/recurrenceUtil.ts @@ -16,18 +16,17 @@ export type APIRecurrenceRuleMonth = export interface APIRecurrenceRule { start: Date; - end?: Date | undefined | null; + end?: Date | null; frequency: APIRecurrenceRuleFrequency; interval: number; - by_weekday?: APIRecurrenceRuleWeekDay[] | undefined | null; + by_weekday?: APIRecurrenceRuleWeekDay[] | null; by_n_weekday?: | { n: 1 | 2 | 3 | 4 | 5; day: APIRecurrenceRuleWeekDay }[] - | undefined | null; - by_month?: APIRecurrenceRuleMonth[] | undefined | null; - by_month_day?: number[] | undefined | null; - by_year_day?: number[] | undefined | null; - count?: number | undefined | null; + by_month?: APIRecurrenceRuleMonth[] | null; + by_month_day?: number[] | null; + by_year_day?: number[] | null; + count?: number | null; } export function getWeekdayString(weekday: APIRecurrenceRuleWeekDay): string { From 8e8c21bb60ab583e3af185ac4c3cb350314edfa0 Mon Sep 17 00:00:00 2001 From: Yuto Terada <66758394+indigo-san@users.noreply.github.com> Date: Mon, 12 Aug 2024 03:54:42 +0900 Subject: [PATCH 15/21] Update src/utils/calendar/types.ts Co-authored-by: Kawahara Shotaro <121674121+k-taro56@users.noreply.github.com> --- src/utils/calendar/types.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/utils/calendar/types.ts b/src/utils/calendar/types.ts index 8e51d47..31620d1 100644 --- a/src/utils/calendar/types.ts +++ b/src/utils/calendar/types.ts @@ -1,11 +1,11 @@ export interface ScheduledEvent { id: string; name: string; - description?: string | undefined | null; + description?: string | null; starttime: Date; - endtime?: Date | undefined | null; - creatorid?: string | undefined | null; - location?: string | undefined | null; - recurrence?: string | undefined | null; - url?: string | undefined | null; + endtime?: Date | null; + creatorid?: string | null; + location?: string | null; + recurrence?: string | null; + url?: string | null; } From 4fbc21d19d2bc157b1e8070dd5dabab6292bfc1a Mon Sep 17 00:00:00 2001 From: Yuto Terada <66758394+indigo-san@users.noreply.github.com> Date: Tue, 13 Aug 2024 18:24:31 +0900 Subject: [PATCH 16/21] Update src/auth.ts Co-authored-by: Kawahara Shotaro <121674121+k-taro56@users.noreply.github.com> --- src/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth.ts b/src/auth.ts index e484c16..2560d4b 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -172,7 +172,7 @@ export const config = async (request: NextRequest | undefined) => { params: { // https://github.com/nextauthjs/next-auth/blob/748c9ecb8ce10bef2b628520451f676db0499f9d/docs/pages/guides/configuring-oauth-providers.mdx scope: 'openid https://www.googleapis.com/auth/calendar', - // https://next-auth.js.org/providers/google + // https://authjs.dev/getting-started/providers/google prompt: 'consent', access_type: 'offline', response_type: 'code', From 54062ae524272171264cdd64a0ec4aa5e546fe57 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Tue, 13 Aug 2024 19:03:59 +0900 Subject: [PATCH 17/21] feat: Added Google Calendar API request body type --- src/utils/calendar/calendarService.ts | 11 ++--- src/utils/calendar/types.ts | 67 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/utils/calendar/calendarService.ts b/src/utils/calendar/calendarService.ts index d47d393..cac2025 100644 --- a/src/utils/calendar/calendarService.ts +++ b/src/utils/calendar/calendarService.ts @@ -1,10 +1,9 @@ // Google Calendarの操作用 -import type { ScheduledEvent } from './types'; +import type { GoogleCalendarEvent, ScheduledEvent } from './types'; function createSchemaEvent(event: ScheduledEvent) { - // biome-ignore lint/suspicious/noExplicitAny: - const body: any = { + const body: GoogleCalendarEvent = { location: event.location, id: event.id, summary: event.name, @@ -46,7 +45,7 @@ export async function createCalEvent( headers: { Authorization: `Bearer ${access_token}`, }, - body: body, + body: JSON.stringify(body), }, ); @@ -59,7 +58,7 @@ export async function createCalEvent( headers: { Authorization: `Bearer ${access_token}`, }, - body: body, + body: JSON.stringify(body), }, ); } @@ -77,7 +76,7 @@ export async function updateCalEvent( headers: { Authorization: `Bearer ${access_token}`, }, - body: body, + body: JSON.stringify(body), }, ); } diff --git a/src/utils/calendar/types.ts b/src/utils/calendar/types.ts index 31620d1..76b3082 100644 --- a/src/utils/calendar/types.ts +++ b/src/utils/calendar/types.ts @@ -9,3 +9,70 @@ export interface ScheduledEvent { recurrence?: string | null; url?: string | null; } + +export interface GoogleCalendarEventDateTime { + /** + * The date, in the format "yyyy-mm-dd", if this is an all-day event. + */ + date?: string | null; + /** + * The time, as a combined date-time value (formatted according to RFC3339). A time zone offset is required unless a time zone is explicitly specified in timeZone. + */ + dateTime?: string | null; + /** + * The time zone in which the time is specified. (Formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich".) For recurring events this field is required and specifies the time zone in which the recurrence is expanded. For single events this field is optional and indicates a custom time zone for the event start/end. + */ + timeZone?: string | null; +} + +export interface GoogleCalendarEvent { + /** + * The color of the event. This is an ID referring to an entry in the event section of the colors definition (see the colors endpoint). Optional. + */ + colorId?: string | null; + /** + * Description of the event. Can contain HTML. Optional. + */ + description?: string | null; + /** + * The (exclusive) end time of the event. For a recurring event, this is the end time of the first instance. + */ + end?: GoogleCalendarEventDateTime; + id?: string | null; + /** + * Geographic location of the event as free-form text. Optional. + */ + location?: string | null; + /** + * List of RRULE, EXRULE, RDATE and EXDATE lines for a recurring event, as specified in RFC5545. Note that DTSTART and DTEND lines are not allowed in this field; event start and end times are specified in the start and end fields. This field is omitted for single events or instances of recurring events. + */ + recurrence?: string[] | null; + /** + * Source from which the event was created. For example, a web page, an email message or any document identifiable by an URL with HTTP or HTTPS scheme. Can only be seen or modified by the creator of the event. + */ + source?: { + title?: string; + url?: string; + } | null; + /** + * The (inclusive) start time of the event. For a recurring event, this is the start time of the first instance. + */ + start?: GoogleCalendarEventDateTime; + /** + * Status of the event. Optional. Possible values are: + * - "confirmed" - The event is confirmed. This is the default status. + * - "tentative" - The event is tentatively confirmed. + * - "cancelled" - The event is cancelled (deleted). The list method returns cancelled events only on incremental sync (when syncToken or updatedMin are specified) or if the showDeleted flag is set to true. The get method always returns them. + * A cancelled status represents two different states depending on the event type: + * - Cancelled exceptions of an uncancelled recurring event indicate that this instance should no longer be presented to the user. Clients should store these events for the lifetime of the parent recurring event. + * Cancelled exceptions are only guaranteed to have values for the id, recurringEventId and originalStartTime fields populated. The other fields might be empty. + * - All other cancelled events represent deleted events. Clients should remove their locally synced copies. Such cancelled events will eventually disappear, so do not rely on them being available indefinitely. + * Deleted events are only guaranteed to have the id field populated. On the organizer's calendar, cancelled events continue to expose event details (summary, location, etc.) so that they can be restored (undeleted). Similarly, the events to which the user was invited and that they manually removed continue to provide details. However, incremental sync requests with showDeleted set to false will not return these details. + * If an event changes its organizer (for example via the move operation) and the original organizer is not on the attendee list, it will leave behind a cancelled event where only the id field is guaranteed to be populated. + */ + status?: string | null; + /** + * Title of the event. + */ + summary?: string | null; +} From 6ca6ea9b6254349cc7d2bb1a5b3ed9178d6c8162 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Tue, 13 Aug 2024 19:05:55 +0900 Subject: [PATCH 18/21] chore: Add description to biome-ignore --- src/utils/calendar/mapping.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/calendar/mapping.ts b/src/utils/calendar/mapping.ts index fb27804..4011bb6 100644 --- a/src/utils/calendar/mapping.ts +++ b/src/utils/calendar/mapping.ts @@ -16,9 +16,9 @@ export function transformAPIGuildScheduledEventToScheduledEvent( : null, creatorid: event.creator_id ?? null, location: event.entity_metadata?.location ?? null, - // biome-ignore lint/suspicious/noExplicitAny: + // biome-ignore lint/suspicious/noExplicitAny: discord-api-typesに含まれないフィールドを取り出すため. https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event recurrence: (event as any).recurrence_rule - ? // biome-ignore lint/suspicious/noExplicitAny: + ? // biome-ignore lint/suspicious/noExplicitAny: discord-api-typesに含まれないフィールドを取り出すため. https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event convertRFC5545RecurrenceRule((event as any).recurrence_rule) : null, url: `https://discord.com/events/${event.guild_id}/${event.id}`, From 778b6efe87665dd897615e57a2a71708a10b39e8 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 14 Aug 2024 04:23:53 +0900 Subject: [PATCH 19/21] chore: rename linkAccount function called at "Update Google Profile" to updateAccount --- src/auth.ts | 13 ++-- src/db/adapter-pg.ts | 173 +++++++++++++++++++++---------------------- 2 files changed, 92 insertions(+), 94 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 2560d4b..2bbc5f1 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -11,7 +11,7 @@ import Discord, { type DiscordProfile } from 'next-auth/providers/discord'; import GitHub, { type GitHubProfile } from 'next-auth/providers/github'; import type { NextRequest } from 'next/server'; -import PostgresAdapter from '@/db/adapter-pg'; +import PostgresAdapter, { updateAccount } from '@/db/adapter-pg'; import { sendAuditLog } from '@/utils/audit-log'; import { isJoinedGuild, sendDirectMessage } from '@/utils/discord'; import { isJoinedOrganization } from '@/utils/github'; @@ -43,11 +43,13 @@ const updateAdapterUser = async ({ account, profile, adapter, + pool, }: { adapterUser: AdapterUser | undefined; account: Account | null; profile: Profile | undefined; adapter: Adapter; + pool: Pool; }) => { if (adapterUser && adapter.updateUser) { if (account?.provider === 'discord') { @@ -75,11 +77,8 @@ const updateAdapterUser = async ({ isJoinedOrganization: isJoinedOrganization, }); } else if (account?.provider === 'google') { - adapter.linkAccount?.({ - ...account, - type: account.type as AdapterAccountType, - userId: adapterUser.id, - }); + updateAccount(pool, account); + await adapter.updateUser({ ...adapterUser, googleUserID: account.providerAccountId, @@ -135,7 +134,7 @@ export const config = async (request: NextRequest | undefined) => { userID: discordUserID, message: `[NID.kt](https://discord.gg/nid-kt) の Web サイトへようこそ!✨🙌🏻\n\`${account?.provider}\` でログインしました ✅`, }), - updateAdapterUser({ adapterUser, account, profile, adapter }), + updateAdapterUser({ adapterUser, account, profile, adapter, pool }), ]); return true; diff --git a/src/db/adapter-pg.ts b/src/db/adapter-pg.ts index 497fb2c..53611d1 100644 --- a/src/db/adapter-pg.ts +++ b/src/db/adapter-pg.ts @@ -1,5 +1,6 @@ import { default as OriginalPostgresAdapter } from '@auth/pg-adapter'; import type { Pool } from '@neondatabase/serverless'; +import type { Account } from 'next-auth'; import type { Adapter, AdapterAccount } from 'next-auth/adapters'; // biome-ignore lint:noExplicitAny - This is a type from the database @@ -142,92 +143,90 @@ export default function PostgresAdapter(client: Pool): Adapter { ]); return query2.rows[0]; }, - async linkAccount(account) { - const exists = await client.query( - `SELECT EXISTS ( - SELECT 1 - FROM accounts - WHERE "userId" = $1 AND provider = $2 AND "providerAccountId" = $3 - );`, - [account.userId, account.provider, account.providerAccountId], - ); - - let sql: string; - if (exists.rows[0].exists) { - sql = ` - update accounts set - type = $3, - access_token = $5, - expires_at = $6, - refresh_token = $7, - id_token = $8, - scope = $9, - session_state = $10, - token_type = $11 - where "userId" = $1 AND provider = $2 AND "providerAccountId" = $4 - returning - id, - "userId", - provider, - type, - "providerAccountId", - access_token, - expires_at, - refresh_token, - id_token, - scope, - session_state, - token_type - `; - } else { - sql = ` - insert into accounts - ( - "userId", - provider, - type, - "providerAccountId", - access_token, - expires_at, - refresh_token, - id_token, - scope, - session_state, - token_type - ) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - returning - id, - "userId", - provider, - type, - "providerAccountId", - access_token, - expires_at, - refresh_token, - id_token, - scope, - session_state, - token_type - `; - } - - const params = [ - account.userId, - account.provider, - account.type, - account.providerAccountId, - account.access_token, - account.expires_at, - account.refresh_token, - account.id_token, - account.scope, - account.session_state, - account.token_type, - ]; - - const result = await client.query(sql, params); - return mapExpiresAt(result.rows[0]); - }, }; } + +export async function updateAccount(pool: Pool, account: Account) { + const exists = await pool.query( + `SELECT EXISTS ( + SELECT 1 + FROM accounts + WHERE "userId" = $1 AND provider = $2 AND "providerAccountId" = $3 + );`, + [account.userId, account.provider, account.providerAccountId], + ); + let sql: string; + if (exists.rows[0].exists) { + sql = ` + update accounts set + type = $3, + access_token = $5, + expires_at = $6, + refresh_token = $7, + id_token = $8, + scope = $9, + session_state = $10, + token_type = $11 + where "userId" = $1 AND provider = $2 AND "providerAccountId" = $4 + returning + id, + "userId", + provider, + type, + "providerAccountId", + access_token, + expires_at, + refresh_token, + id_token, + scope, + session_state, + token_type + `; + } else { + sql = ` + insert into accounts + ( + "userId", + provider, + type, + "providerAccountId", + access_token, + expires_at, + refresh_token, + id_token, + scope, + session_state, + token_type + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + returning + id, + "userId", + provider, + type, + "providerAccountId", + access_token, + expires_at, + refresh_token, + id_token, + scope, + session_state, + token_type + `; + } + const params = [ + account.userId, + account.provider, + account.type, + account.providerAccountId, + account.access_token, + account.expires_at, + account.refresh_token, + account.id_token, + account.scope, + account.session_state, + account.token_type, + ]; + const result = await pool.query(sql, params); + return mapExpiresAt(result.rows[0]); +} From 1a10f49c5fc5c06b1890d5e3624143090528b9fc Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 14 Aug 2024 04:29:07 +0900 Subject: [PATCH 20/21] feat: Delete refresh token from db when it is invalid --- src/utils/calendar/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/utils/calendar/index.ts b/src/utils/calendar/index.ts index bcff8f0..5f6b430 100644 --- a/src/utils/calendar/index.ts +++ b/src/utils/calendar/index.ts @@ -42,6 +42,14 @@ async function retrieveUserToken(id: string): Promise<{ if (userToken.expires_at < Math.floor(Date.now() / 1000) + 60) { const json = await refreshAccessToken(userToken.refresh_token); if (!json) { + // リフレッシュトークンが無効な場合、削除とgoogleUserIdのnull設定 + await sql` + DELETE FROM accounts WHERE "userId" = ${id} AND provider = 'google'; + `; + await sql` + UPDATE users SET "googleUserId" = NULL WHERE id = ${id}; + `; + return null; } From 0350ad811aa3c6365ba781fe33decdfacd5ccec5 Mon Sep 17 00:00:00 2001 From: Yuto Terada Date: Wed, 14 Aug 2024 04:30:39 +0900 Subject: [PATCH 21/21] feat: Show calendar link errors --- src/app/page.tsx | 35 ++++-------------------- src/components/ButtonInForm.tsx | 18 +++++++++++++ src/components/LinkCalendarButton.tsx | 21 +++++++++++++++ src/utils/calendar/index.ts | 38 +++++++++++++++++++-------- 4 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 src/components/ButtonInForm.tsx create mode 100644 src/components/LinkCalendarButton.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index be569f6..1379e8c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,24 +1,8 @@ import { auth, signIn, signOut } from '@/auth'; -import { linkCalendar, unlinkCalendar } from '@/utils/calendar'; +import ButtonInForm from '@/components/ButtonInForm'; +import LinkCalendarButton from '@/components/LinkCalendarButton'; import { createOrganizationInvitation } from '@/utils/github'; -const ButtonInForm = ({ - action, - text, -}: { - action: () => void; - text: string; -}) => ( -
- -
-); - const SignInButton = ({ service, text, @@ -96,19 +80,10 @@ export default async function Home() { <> {!isLinkedToCalendar ? ( - { - 'use server'; - await linkCalendar(); - }} - text='Link to Google Calendar' - /> + ) : ( - { - 'use server'; - await unlinkCalendar(); - }} + )} diff --git a/src/components/ButtonInForm.tsx b/src/components/ButtonInForm.tsx new file mode 100644 index 0000000..6ab63db --- /dev/null +++ b/src/components/ButtonInForm.tsx @@ -0,0 +1,18 @@ +export default function ButtonInForm({ + action, + text, +}: { + action: JSX.IntrinsicElements['form']['action']; + text: string; +}) { + return ( +
+ +
+ ); +} diff --git a/src/components/LinkCalendarButton.tsx b/src/components/LinkCalendarButton.tsx new file mode 100644 index 0000000..e792230 --- /dev/null +++ b/src/components/LinkCalendarButton.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { linkCalendar, unlinkCalendar } from '@/utils/calendar'; +import { useFormState } from 'react-dom'; +import ButtonInForm from './ButtonInForm'; + +export default function LinkCalendarButton({ + action, + text, +}: { action: 'link' | 'unlink'; text: string }) { + const [state, dispatch] = useFormState( + action === 'link' ? linkCalendar : unlinkCalendar, + { error: '' }, + ); + return ( + <> + + {state.error &&

{state.error}

} + + ); +} diff --git a/src/utils/calendar/index.ts b/src/utils/calendar/index.ts index 5f6b430..09a826d 100644 --- a/src/utils/calendar/index.ts +++ b/src/utils/calendar/index.ts @@ -1,8 +1,10 @@ +'use server'; + import { auth } from '@/auth'; import { REST } from '@discordjs/rest'; import { sql } from '@vercel/postgres'; import { type APIGuildScheduledEvent, Routes } from 'discord-api-types/v10'; -import { redirect } from 'next/navigation'; +import { revalidatePath } from 'next/cache'; import { createCalEvent, removeCalEvent } from './calendarService'; import { transformAPIGuildScheduledEventToScheduledEvent } from './mapping'; @@ -95,15 +97,21 @@ async function updateIsLinkedToCalendar(id: string, value: boolean) { `; } -export async function linkCalendar() { +export async function linkCalendar( + state: { error?: string }, + formData: FormData, +) { const session = await auth(); const user = session?.user; - if (!user || !user.id || user.isLinkedToCalendar) { - return; + if (!user || !user.id) { + return { error: 'サインインが必要です' }; + } + if (user.isLinkedToCalendar) { + return { error: 'すでにリンクされています' }; } const userAndToken = await retrieveUserToken(user.id); if (!userAndToken) { - return; + return { error: 'Googleの認証が必要です' }; } await updateIsLinkedToCalendar(user.id, true); @@ -120,18 +128,25 @@ export async function linkCalendar() { transformAPIGuildScheduledEventToScheduledEvent(event), ); } - redirect('/'); + revalidatePath('/'); + return { error: undefined }; } -export async function unlinkCalendar() { +export async function unlinkCalendar( + state: { error?: string }, + formData: FormData, +) { const session = await auth(); const user = session?.user; - if (!user || !user.id || !user.isLinkedToCalendar) { - return; + if (!user || !user.id) { + return { error: 'サインインが必要です' }; + } + if (!user.isLinkedToCalendar) { + return { error: 'すでにリンク解除されています' }; } const userAndToken = await retrieveUserToken(user.id); if (!userAndToken) { - return; + return { error: 'Googleの認証が必要です' }; } await updateIsLinkedToCalendar(user.id as string, false); @@ -145,5 +160,6 @@ export async function unlinkCalendar() { for (const event of events) { removeCalEvent(userAndToken.access_token, event); } - redirect('/'); + revalidatePath('/'); + return { error: undefined }; }