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= diff --git a/package.json b/package.json index 9d29b07..6ed7a19 100644 --- a/package.json +++ b/package.json @@ -13,7 +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 45cc66b..1a14463 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +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) @@ -136,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'} @@ -237,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==} @@ -261,6 +290,14 @@ 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'} + + '@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'} @@ -305,6 +342,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'} @@ -381,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==} @@ -551,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==} @@ -623,6 +673,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 +1027,14 @@ 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'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -993,6 +1055,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'} @@ -1060,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 @@ -1136,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': @@ -1166,6 +1260,15 @@ 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) + + '@vladfrangu/async_event_emitter@2.4.5': {} + ansi-escapes@7.0.0: dependencies: environment: 1.1.0 @@ -1201,6 +1304,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 @@ -1264,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: {} @@ -1430,6 +1541,8 @@ snapshots: lru-cache@10.2.2: {} + magic-bytes.js@1.10.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -1490,6 +1603,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 +1930,12 @@ snapshots: undici-types@6.13.0: {} + undici@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 +1960,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/app/page.tsx b/src/app/page.tsx index 16facf5..1379e8c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,28 +1,13 @@ import { auth, signIn, signOut } from '@/auth'; +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, }: { - service: 'discord' | 'github'; + service: 'discord' | 'github' | 'google'; text: string; }) => (

Welcome {name}

+ {!isJoinedGuild ? ( ) : !githubUserID ? ( @@ -75,10 +67,29 @@ export default async function Home() { ) : ( <> )} + + {isJoinedGuild && !googleUserID && ( + + )} + {githubUserID && ( )} + {googleUserID && ( + <> + + {!isLinkedToCalendar ? ( + + ) : ( + + )} + + )} + { 'use server'; diff --git a/src/auth.ts b/src/auth.ts index 207700a..2bbc5f1 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,12 +1,17 @@ +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'; -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'; @@ -38,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') { @@ -69,6 +76,13 @@ const updateAdapterUser = async ({ githubUserName: githubUserName, isJoinedOrganization: isJoinedOrganization, }); + } else if (account?.provider === 'google') { + updateAccount(pool, account); + + await adapter.updateUser({ + ...adapterUser, + googleUserID: account.providerAccountId, + }); } } }; @@ -120,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; @@ -152,6 +166,18 @@ 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', + // https://authjs.dev/getting-started/providers/google + prompt: 'consent', + access_type: 'offline', + response_type: 'code', + }, + }, + }), ], theme: { logo: '/icon.png' }, } satisfies NextAuthConfig; 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/db/adapter-pg.ts b/src/db/adapter-pg.ts index 3173a69..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 @@ -22,9 +23,11 @@ export default function PostgresAdapter(client: Pool): Adapter { image, isJoinedGuild, isJoinedOrganization, + isLinkedToCalendar, githubUserID, githubUserName, discordUserID, + googleUserID, } = user; const sql = ` INSERT INTO users @@ -35,11 +38,13 @@ export default function PostgresAdapter(client: Pool): Adapter { image, "isJoinedGuild", "isJoinedOrganization", + "isLinkedToCalendar", "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, $11) RETURNING id, name, @@ -48,9 +53,11 @@ export default function PostgresAdapter(client: Pool): Adapter { image, "isJoinedGuild", "isJoinedOrganization", + "isLinkedToCalendar", "githubUserID", "githubUserName", - "discordUserID" + "discordUserID", + "googleUserID" `; const result = await client.query(sql, [ name, @@ -59,9 +66,11 @@ export default function PostgresAdapter(client: Pool): Adapter { image, isJoinedGuild, isJoinedOrganization, + isLinkedToCalendar, githubUserID, githubUserName, discordUserID, + googleUserID, ]); return result.rows[0]; }, @@ -83,9 +92,11 @@ export default function PostgresAdapter(client: Pool): Adapter { image, isJoinedGuild, isJoinedOrganization, + isLinkedToCalendar, githubUserID, githubUserName, discordUserID, + googleUserID, } = newUser; const updateSql = ` @@ -96,9 +107,11 @@ export default function PostgresAdapter(client: Pool): Adapter { image = $5, "isJoinedGuild" = $6, "isJoinedOrganization" = $7, - "githubUserID" = $8, - "githubUserName" = $9, - "discordUserID" = $10 + "isLinkedToCalendar" = $8, + "githubUserID" = $9, + "githubUserName" = $10, + "discordUserID" = $11, + "googleUserID" = $12 where id = $1 RETURNING name, @@ -108,9 +121,11 @@ export default function PostgresAdapter(client: Pool): Adapter { image, "isJoinedGuild", "isJoinedOrganization", + "isLinkedToCalendar", "githubUserID", "githubUserName", - "discordUserID" + "discordUserID", + "googleUserID" `; const query2 = await client.query(updateSql, [ id, @@ -120,11 +135,98 @@ export default function PostgresAdapter(client: Pool): Adapter { image, isJoinedGuild, isJoinedOrganization, + isLinkedToCalendar, githubUserID, githubUserName, discordUserID, + googleUserID, ]); return query2.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]); +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 1e99ab1..a2b6d8f 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -5,9 +5,11 @@ declare module 'next-auth' { interface User { isJoinedGuild?: boolean; isJoinedOrganization?: boolean; + isLinkedToCalendar?: boolean; githubUserID?: number; githubUserName?: string; discordUserID?: string; + googleUserID?: string; } } diff --git a/src/utils/calendar/calendarService.ts b/src/utils/calendar/calendarService.ts new file mode 100644 index 0000000..cac2025 --- /dev/null +++ b/src/utils/calendar/calendarService.ts @@ -0,0 +1,97 @@ +// Google Calendarの操作用 + +import type { GoogleCalendarEvent, ScheduledEvent } from './types'; + +function createSchemaEvent(event: ScheduledEvent) { + const body: GoogleCalendarEvent = { + 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: JSON.stringify(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: JSON.stringify(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: JSON.stringify(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..09a826d --- /dev/null +++ b/src/utils/calendar/index.ts @@ -0,0 +1,165 @@ +'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 { revalidatePath } from 'next/cache'; +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) { + // リフレッシュトークンが無効な場合、削除と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; + } + + 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( + state: { error?: string }, + formData: FormData, +) { + const session = await auth(); + const user = session?.user; + if (!user || !user.id) { + return { error: 'サインインが必要です' }; + } + if (user.isLinkedToCalendar) { + return { error: 'すでにリンクされています' }; + } + const userAndToken = await retrieveUserToken(user.id); + if (!userAndToken) { + return { error: 'Googleの認証が必要です' }; + } + + 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), + ); + } + revalidatePath('/'); + return { error: undefined }; +} + +export async function unlinkCalendar( + state: { error?: string }, + formData: FormData, +) { + const session = await auth(); + const user = session?.user; + if (!user || !user.id) { + return { error: 'サインインが必要です' }; + } + if (!user.isLinkedToCalendar) { + return { error: 'すでにリンク解除されています' }; + } + const userAndToken = await retrieveUserToken(user.id); + if (!userAndToken) { + return { error: 'Googleの認証が必要です' }; + } + + 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); + } + revalidatePath('/'); + return { error: undefined }; +} diff --git a/src/utils/calendar/mapping.ts b/src/utils/calendar/mapping.ts new file mode 100644 index 0000000..4011bb6 --- /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: 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: 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}`, + }; +} diff --git a/src/utils/calendar/recurrenceUtil.ts b/src/utils/calendar/recurrenceUtil.ts new file mode 100644 index 0000000..f21ad10 --- /dev/null +++ b/src/utils/calendar/recurrenceUtil.ts @@ -0,0 +1,76 @@ +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 | null; + frequency: APIRecurrenceRuleFrequency; + interval: number; + by_weekday?: APIRecurrenceRuleWeekDay[] | null; + by_n_weekday?: + | { n: 1 | 2 | 3 | 4 | 5; day: APIRecurrenceRuleWeekDay }[] + | null; + by_month?: APIRecurrenceRuleMonth[] | null; + by_month_day?: number[] | null; + by_year_day?: number[] | null; + count?: number | 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(',')}`; + } + + if (rule.end) { + str += `;UNTIL=${rule.end.toISOString().replace(/[-:]/g, '').split('.')[0]}Z`; + } + 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..76b3082 --- /dev/null +++ b/src/utils/calendar/types.ts @@ -0,0 +1,78 @@ +export interface ScheduledEvent { + id: string; + name: string; + description?: string | null; + starttime: Date; + endtime?: Date | null; + creatorid?: string | null; + location?: string | null; + 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; +}