diff --git a/apps/admin-dashboard/app/routes/_dashboard.students.$id.activate.tsx b/apps/admin-dashboard/app/routes/_dashboard.students.$id.activate.tsx index ef53b4590..c1c22646d 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.students.$id.activate.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.students.$id.activate.tsx @@ -86,8 +86,8 @@ export default function ActivateStudentPage() { Just confirming - do you want to activate {student.firstName}{' '} - {student.lastName}? They will receive an email with the ability to claim - a swag pack in their profile. + {student.lastName}? They will receive an email with a gift card to the + merch store. diff --git a/apps/api/.env.example b/apps/api/.env.example index 91edc761b..52ee091a8 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -34,6 +34,8 @@ STUDENT_PROFILE_URL=http://localhost:3000 # R2_BUCKET_NAME= # R2_SECRET_ACCESS_KEY= # SENTRY_DSN= +# SHOPIFY_ACCESS_TOKEN= +# SHOPIFY_STORE_NAME= # SLACK_ANNOUNCEMENTS_CHANNEL_ID= # SLACK_ADMIN_TOKEN= # SLACK_BIRTHDATE_FIELD_ID= @@ -47,5 +49,3 @@ STUDENT_PROFILE_URL=http://localhost:3000 # SMTP_HOST= # SMTP_PASSWORD= # SMTP_USERNAME= -# SWAG_UP_CLIENT_ID= -# SWAG_UP_CLIENT_SECRET= diff --git a/apps/api/src/shared/env.ts b/apps/api/src/shared/env.ts index a5ed001d5..d075fa293 100644 --- a/apps/api/src/shared/env.ts +++ b/apps/api/src/shared/env.ts @@ -43,6 +43,8 @@ const BaseEnvironmentConfig = z.object({ R2_SECRET_ACCESS_KEY: EnvironmentVariable, REDIS_URL: EnvironmentVariable, SENTRY_DSN: EnvironmentVariable, + SHOPIFY_ACCESS_TOKEN: EnvironmentVariable, + SHOPIFY_STORE_NAME: EnvironmentVariable, SLACK_ANNOUNCEMENTS_CHANNEL_ID: EnvironmentVariable, SLACK_ADMIN_TOKEN: EnvironmentVariable, SLACK_BIRTHDATE_FIELD_ID: EnvironmentVariable, @@ -54,8 +56,6 @@ const BaseEnvironmentConfig = z.object({ SLACK_INTRODUCTIONS_CHANNEL_ID: EnvironmentVariable, SLACK_SIGNING_SECRET: EnvironmentVariable, STUDENT_PROFILE_URL: EnvironmentVariable, - SWAG_UP_CLIENT_ID: EnvironmentVariable, - SWAG_UP_CLIENT_SECRET: EnvironmentVariable, }); const EnvironmentConfig = z.discriminatedUnion('ENVIRONMENT', [ @@ -84,6 +84,8 @@ const EnvironmentConfig = z.discriminatedUnion('ENVIRONMENT', [ R2_BUCKET_NAME: true, R2_SECRET_ACCESS_KEY: true, SENTRY_DSN: true, + SHOPIFY_ACCESS_TOKEN: true, + SHOPIFY_STORE_NAME: true, SLACK_ANNOUNCEMENTS_CHANNEL_ID: true, SLACK_ADMIN_TOKEN: true, SLACK_BIRTHDATE_FIELD_ID: true, @@ -94,8 +96,6 @@ const EnvironmentConfig = z.discriminatedUnion('ENVIRONMENT', [ SLACK_FEED_CHANNEL_ID: true, SLACK_INTRODUCTIONS_CHANNEL_ID: true, SLACK_SIGNING_SECRET: true, - SWAG_UP_CLIENT_ID: true, - SWAG_UP_CLIENT_SECRET: true, }).extend({ ENVIRONMENT: z.literal(Environment.DEVELOPMENT), SMTP_HOST: EnvironmentVariable.optional(), diff --git a/apps/member-profile/.env.example b/apps/member-profile/.env.example index b8becb72c..bd306ddc3 100644 --- a/apps/member-profile/.env.example +++ b/apps/member-profile/.env.example @@ -33,5 +33,3 @@ STUDENT_PROFILE_URL=http://localhost:3000 # SMTP_HOST= # SMTP_PASSWORD= # SMTP_USERNAME= -# SWAG_UP_CLIENT_ID= -# SWAG_UP_CLIENT_SECRET= diff --git a/apps/member-profile/app/routes/_profile.home.activation.tsx b/apps/member-profile/app/routes/_profile.home.activation.tsx index c4e1ae6d8..1e5de917c 100644 --- a/apps/member-profile/app/routes/_profile.home.activation.tsx +++ b/apps/member-profile/app/routes/_profile.home.activation.tsx @@ -113,14 +113,7 @@ export default function ActivationModal() { function ActivatedState() { return ( <> - - Great news -- you're activated and eligible to{' '} - - claim your FREE swag pack - - ! πŸŽ‰ - - + Great news -- you're activated! πŸŽ‰ ); @@ -167,8 +160,8 @@ function NotActivatedState() { <> You've completed {requirementsCompleted.length}/6 activation - requirements. Once you hit all 6, you will be eligible to claim your - FREE swag pack! πŸ‘€ + requirements. Once you hit all 6, you will get a gift card to claim your + FREE merch! πŸ‘€ diff --git a/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx b/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx deleted file mode 100644 index 9a55acc46..000000000 --- a/apps/member-profile/app/routes/_profile.home.claim-swag-pack._index.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { - type ActionFunctionArgs, - defer, - json, - type LoaderFunctionArgs, - redirect, -} from '@remix-run/node'; -import { - Await, - Form as RemixForm, - useActionData, - useLoaderData, -} from '@remix-run/react'; -import { Suspense } from 'react'; - -import { - claimSwagPack, - getSwagPackInventory, - reportException, -} from '@oyster/core/member-profile/server'; -import { ClaimSwagPackInput } from '@oyster/core/member-profile/ui'; -import { db } from '@oyster/db'; -import { - Address, - Button, - Form, - getErrors, - Modal, - Spinner, - Text, - validateForm, -} from '@oyster/ui'; - -import { Route } from '@/shared/constants'; -import { ensureUserAuthenticated, user } from '@/shared/session.server'; - -export async function loader({ request }: LoaderFunctionArgs) { - const session = await ensureUserAuthenticated(request); - - const student = await db - .selectFrom('students') - .select(['activatedAt', 'claimedSwagPackAt']) - .where('id', '=', user(session)) - .executeTakeFirst(); - - if (!student || student.claimedSwagPackAt || !student.activatedAt) { - throw new Response(null, { status: 404 }); - } - - const inventoryPromise = getSwagPackInventory(); - - return defer({ - inventoryPromise, - }); -} - -export async function action({ request }: ActionFunctionArgs) { - const session = await ensureUserAuthenticated(request); - - const { data, errors, ok } = await validateForm( - request, - ClaimSwagPackInput.omit({ studentId: true }) - ); - - if (!ok) { - return json({ errors }, { status: 400 }); - } - - try { - const result = await claimSwagPack({ - addressCity: data.addressCity, - addressCountry: data.addressCountry, - addressLine1: data.addressLine1, - addressLine2: data.addressLine2, - addressState: data.addressState, - addressZip: data.addressZip, - studentId: user(session), - }); - - if (!result.ok) { - return json({ error: result.error }, { status: result.code }); - } - - return redirect(Route['/home/claim-swag-pack/confirmation']); - } catch (e) { - reportException(e); - - return json({ - error: `Something went wrong. Please double check that you have a valid address. If you are still having trouble, reach out to membership@colorstack.org for further assistance.`, - errors, - }); - } -} - -const keys = ClaimSwagPackInput.keyof().enum; - -export default function ClaimSwagPack() { - const { inventoryPromise } = useLoaderData(); - - return ( - <> - - Claim Your Swag Pack 🎁 - - - - }> - - {(inventory) => { - return inventory > 0 ? ( - - ) : ( - - Unfortunately, we ran out of swag pack inventory. However, we're - restocking ASAP and you should be able to claim a pack in the - next 2-4 weeks. Sorry about any inconvenience and thank you for - your patience! - - ); - }} - - - - ); -} - -function LoadingState() { - return ( -
- - - Checking our swag pack inventory... - -
- ); -} - -function ClaimSwagPackForm() { - const { error, errors } = getErrors(useActionData()); - - return ( - - Let us know where to send your swag pack! - -
- - - - - - - - - - - - - - - - - - - - - - - - -
- - {error} - - - Claim Swag Pack - -
- ); -} - -export function ErrorBoundary() { - return <>; -} diff --git a/apps/member-profile/app/routes/_profile.home.claim-swag-pack.confirmation.tsx b/apps/member-profile/app/routes/_profile.home.claim-swag-pack.confirmation.tsx deleted file mode 100644 index 63c4acc73..000000000 --- a/apps/member-profile/app/routes/_profile.home.claim-swag-pack.confirmation.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { json, type LoaderFunctionArgs } from '@remix-run/node'; - -import { Modal } from '@oyster/ui'; - -import { ensureUserAuthenticated } from '@/shared/session.server'; - -export async function loader({ request }: LoaderFunctionArgs) { - await ensureUserAuthenticated(request); - - return json({}); -} - -export default function ConfirmationPage() { - return ( - <> - - Swag Pack Ordered! πŸŽ‰ - - - - - Congratulations! Your ColorStack swag pack is on its way to you. Please - allow 2-4 weeks for shipping and processing. - - - ); -} diff --git a/apps/member-profile/app/routes/_profile.home.claim-swag-pack.tsx b/apps/member-profile/app/routes/_profile.home.claim-swag-pack.tsx index ffd7eca90..a37a4a041 100644 --- a/apps/member-profile/app/routes/_profile.home.claim-swag-pack.tsx +++ b/apps/member-profile/app/routes/_profile.home.claim-swag-pack.tsx @@ -1,23 +1,6 @@ -import { json, type LoaderFunctionArgs } from '@remix-run/node'; -import { Outlet } from '@remix-run/react'; - -import { Modal } from '@oyster/ui'; - -import { Route } from '@/shared/constants'; -import { ensureUserAuthenticated } from '@/shared/session.server'; - -export async function loader({ request }: LoaderFunctionArgs) { - await ensureUserAuthenticated(request, { - redirectTo: `${Route['/login']}?context=claim-swag-pack`, - }); - - return json({}); -} - -export default function ClaimSwagPackLayout() { - return ( - - - +export async function loader() { + return new Response( + 'If you were activated recently, you should have received an email with a gift card to our merch store. Please reach out to the ColorStack team if you need further assistance.', + { status: 404 } ); } diff --git a/apps/member-profile/app/routes/_profile.home.tsx b/apps/member-profile/app/routes/_profile.home.tsx index 70160a181..6abe63ff4 100644 --- a/apps/member-profile/app/routes/_profile.home.tsx +++ b/apps/member-profile/app/routes/_profile.home.tsx @@ -233,20 +233,14 @@ export default function HomeLayout() { const showOnboardingCard = !!student.joinedAfterActivation && !student.onboardedAt; - const showSwagCard = - !!student.joinedAfterActivation && - !!student.activatedAt && - !student.claimedSwagPackAt; - return ( <> Hey, {student.firstName}! πŸ‘‹ - {(showActivationCard || showOnboardingCard || showSwagCard) && ( + {(showActivationCard || showOnboardingCard) && ( <>
{showActivationCard && } - {showSwagCard && } {showOnboardingCard && }
@@ -348,29 +342,6 @@ function ActiveStatusCard() { ); } -function ClaimSwagPackCard() { - return ( - - Claim Swag Pack 🎁 - - - Congratulations on becoming an activated ColorStack member! As a thank - you for engaging in the community, we would love to send you a - ColorStack swag pack. - - - - - Claim Swag Pack - - - - ); -} - function OnboardingSessionCard() { return ( @@ -403,8 +374,8 @@ function ActivationCard() { You've completed {student.activationRequirementsCompleted.length}/6 - activation requirements. Once you hit all 6, you will be eligible to - claim your FREE swag pack! πŸ‘€ + activation requirements. Once you hit all 6, you will get a gift card to + claim your FREE merch! πŸ‘€ diff --git a/apps/member-profile/app/routes/_public.claim-swag-pack.tsx b/apps/member-profile/app/routes/_public.claim-swag-pack.tsx deleted file mode 100644 index 0e1466034..000000000 --- a/apps/member-profile/app/routes/_public.claim-swag-pack.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { redirect } from '@remix-run/node'; - -import { Route } from '@/shared/constants'; - -export function loader() { - return redirect(Route['/home/claim-swag-pack']); -} diff --git a/apps/member-profile/app/routes/_public.login.tsx b/apps/member-profile/app/routes/_public.login.tsx index 0826b3255..e76354a1c 100644 --- a/apps/member-profile/app/routes/_public.login.tsx +++ b/apps/member-profile/app/routes/_public.login.tsx @@ -1,50 +1,16 @@ -import { json, type LoaderFunctionArgs } from '@remix-run/node'; -import { Outlet, useLoaderData } from '@remix-run/react'; -import { z } from 'zod'; +import { json } from '@remix-run/node'; +import { Outlet } from '@remix-run/react'; -import { Login, Public, Text } from '@oyster/ui'; +import { Login, Public } from '@oyster/ui'; -import { Route } from '@/shared/constants'; - -export const LoginSearchParams = z.object({ - context: z.enum(['claim-swag-pack']).nullish().catch(null), -}); - -export type LoginSearchParams = z.infer; - -export async function loader({ request }: LoaderFunctionArgs) { - const url = new URL(request.url); - - const { context } = LoginSearchParams.parse( - Object.fromEntries(url.searchParams) - ); - - const title: string = - context === 'claim-swag-pack' ? 'Claim Swag Pack 🎁' : 'ColorStack Profile'; - - // We're only going to show the description for the Claim Swag Pack flow - // in the initial login page, and not any subsequent OTP (or other) pages. - const isFirstLoginPage: boolean = - new URL(request.url).pathname === Route['/login']; - - const description: string | null = - context === 'claim-swag-pack' && isFirstLoginPage - ? "In order to claim your swag pack, we'll just need to authenticate your email by sending you a one-time passcode." - : null; - - return json({ - description, - title, - }); +export async function loader() { + return json({}); } export default function LoginLayout() { - const { description, title } = useLoaderData(); - return ( - {title} - {description && {description}} + ColorStack Profile ); diff --git a/apps/member-profile/app/shared/constants.server.ts b/apps/member-profile/app/shared/constants.server.ts index 5648b72a3..ce13c92e9 100644 --- a/apps/member-profile/app/shared/constants.server.ts +++ b/apps/member-profile/app/shared/constants.server.ts @@ -31,8 +31,6 @@ const BaseEnvironmentConfig = z.object({ SLACK_CLIENT_ID: EnvironmentVariable, SLACK_TEAM_ID: EnvironmentVariable, STUDENT_PROFILE_URL: EnvironmentVariable, - SWAG_UP_CLIENT_ID: EnvironmentVariable, - SWAG_UP_CLIENT_SECRET: EnvironmentVariable, }); const EnvironmentConfig = z.discriminatedUnion('ENVIRONMENT', [ @@ -56,8 +54,6 @@ const EnvironmentConfig = z.discriminatedUnion('ENVIRONMENT', [ SLACK_ANNOUNCEMENTS_CHANNEL_ID: true, SLACK_CLIENT_ID: true, SLACK_TEAM_ID: true, - SWAG_UP_CLIENT_ID: true, - SWAG_UP_CLIENT_SECRET: true, }).extend({ ENVIRONMENT: z.literal(Environment.DEVELOPMENT), SMTP_HOST: EnvironmentVariable.optional(), diff --git a/apps/member-profile/app/shared/constants.ts b/apps/member-profile/app/shared/constants.ts index a525fbace..833daa1a1 100644 --- a/apps/member-profile/app/shared/constants.ts +++ b/apps/member-profile/app/shared/constants.ts @@ -20,8 +20,6 @@ const ROUTES = [ '/events/upcoming/:id/registrations', '/home', '/home/activation', - '/home/claim-swag-pack', - '/home/claim-swag-pack/confirmation', '/login', '/login/otp/send', '/login/otp/verify', diff --git a/docs/how-to-enable-integrations.md b/docs/how-to-enable-integrations.md index ec215b27c..e1fc42def 100644 --- a/docs/how-to-enable-integrations.md +++ b/docs/how-to-enable-integrations.md @@ -149,19 +149,3 @@ To enable the **Slack** integration: SLACK_CLIENT_ID SLACK_TEAM_ID ``` - -## SwagUp - -To enable the **SwagUp** integration: - -1. -2. In `/api/.env`, set the following variables: - ``` - SWAG_UP_CLIENT_ID - SWAG_UP_CLIENT_SECRET - ``` -3. In `/member-profile/.env`, set the following variables: - ``` - SWAG_UP_CLIENT_ID - SWAG_UP_CLIENT_SECRET - ``` diff --git a/packages/core/package.json b/packages/core/package.json index e5cd90ab8..f3e9faef7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,7 +31,7 @@ "./gamification/types": "./src/modules/gamification/gamification.types.ts", "./gamification/ui": "./src/modules/gamification/gamification.ui.tsx", "./github": "./src/modules/github/github.ts", - "./goody": "./src/modules/goody/goody.ts", + "./goody": "./src/modules/goody.ts", "./location": "./src/modules/location/location.core.ts", "./location/types": "./src/modules/location/location.types.ts", "./location/ui": "./src/modules/location/location.ui.tsx", diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts index f6c09cda6..516b957bb 100644 --- a/packages/core/src/api.ts +++ b/packages/core/src/api.ts @@ -20,7 +20,6 @@ import { profileWorker } from './modules/member/profile.worker'; import { notificationWorker } from './modules/notification/notification.worker'; import { onboardingSessionWorker } from './modules/onboarding-session/onboarding-session.worker'; import { slackWorker } from './modules/slack/slack.worker'; -import { swagPackWorker } from './modules/swag-pack/swag-pack.worker'; export { job } from './infrastructure/bull/use-cases/job'; export { OAuthCodeState } from './modules/authentication/authentication.types'; @@ -48,5 +47,4 @@ export function startBullWorkers(): void { oneTimeCodeWorker.run(); profileWorker.run(); slackWorker.run(); - swagPackWorker.run(); } diff --git a/packages/core/src/infrastructure/bull/bull.types.ts b/packages/core/src/infrastructure/bull/bull.types.ts index ceac0becb..ed227e34a 100644 --- a/packages/core/src/infrastructure/bull/bull.types.ts +++ b/packages/core/src/infrastructure/bull/bull.types.ts @@ -39,7 +39,6 @@ export const BullQueue = { PROFILE: 'profile', SLACK: 'slack', STUDENT: 'student', - SWAG_PACK: 'swag_pack', } as const; export type BullQueue = ExtractValue; @@ -593,13 +592,6 @@ export const StudentBullJob = z.discriminatedUnion('name', [ }), ]); -export const SwagPackBullJob = z.discriminatedUnion('name', [ - z.object({ - name: z.literal('swag_pack.inventory.notify'), - data: z.object({}), - }), -]); - // Combination export const BullJob = z.union([ @@ -616,7 +608,6 @@ export const BullJob = z.union([ ProfileBullJob, SlackBullJob, StudentBullJob, - SwagPackBullJob, ]); // Types diff --git a/packages/core/src/member-profile.server.ts b/packages/core/src/member-profile.server.ts index a719fe9cd..9b383a7c7 100644 --- a/packages/core/src/member-profile.server.ts +++ b/packages/core/src/member-profile.server.ts @@ -37,5 +37,3 @@ export { updateAllowEmailShare } from './modules/member/use-cases/update-allow-e export { updateMember } from './modules/member/use-cases/update-member'; export { reportException } from './modules/sentry/use-cases/report-exception'; export { countMessagesSent } from './modules/slack/queries/count-messages-sent'; -export { getSwagPackInventory } from './modules/swag-pack/swag-pack.service'; -export { claimSwagPack } from './modules/swag-pack/use-cases/claim-swag-pack'; diff --git a/packages/core/src/member-profile.ui.ts b/packages/core/src/member-profile.ui.ts index af9d16b7a..21cdee565 100644 --- a/packages/core/src/member-profile.ui.ts +++ b/packages/core/src/member-profile.ui.ts @@ -39,5 +39,4 @@ export { ListMembersInDirectoryWhere, } from './modules/member/member.types'; export { CreateResumeBookInput } from './modules/resume/resume.types'; -export { ClaimSwagPackInput } from './modules/swag-pack/swag-pack.types'; export { Environment, ListSearchParams } from './shared/types'; diff --git a/packages/core/src/modules/goody/goody.ts b/packages/core/src/modules/goody.ts similarity index 100% rename from packages/core/src/modules/goody/goody.ts rename to packages/core/src/modules/goody.ts diff --git a/packages/core/src/modules/member/events/activation-step-completed.ts b/packages/core/src/modules/member/events/activation-step-completed.ts index fb803cbe7..3f9e42560 100644 --- a/packages/core/src/modules/member/events/activation-step-completed.ts +++ b/packages/core/src/modules/member/events/activation-step-completed.ts @@ -5,6 +5,7 @@ import { db } from '@oyster/db'; import { ActivationRequirement, type Student } from '@oyster/types'; import { job } from '@/infrastructure/bull/use-cases/job'; +import { activateMember } from '@/modules/member/use-cases/activate-member'; import { ENV } from '@/shared/env'; import { ErrorWithContext } from '@/shared/errors'; @@ -78,7 +79,7 @@ export async function onActivationStepCompleted( }); if (activated) { - await activateStudent(studentId); + await activateMember(studentId); } const [newRequirementCompleted] = updatedCompletedRequirements.filter( @@ -212,18 +213,6 @@ async function updateCompletedRequirements(studentId: string) { return updatedRequirements; } -async function activateStudent(id: string) { - await db - .updateTable('students') - .set({ activatedAt: new Date() }) - .where('id', '=', id) - .execute(); - - job('student.activated', { - studentId: id, - }); -} - async function sendProgressNotification({ activationRequirementsCompleted, firstName, @@ -241,7 +230,7 @@ async function sendProgressNotification({ You've completed all of your activation requirements, which means...you are now an *activated* ColorStack member. - You can now claim your free swag pack in your ! 🎁 + Look out for an email that includes a GIFT CARD to the ! 🎁 `; } else { message = dedent` diff --git a/packages/core/src/modules/member/events/member-activated.ts b/packages/core/src/modules/member/events/member-activated.ts index 1072fc27a..a48bb3dfd 100644 --- a/packages/core/src/modules/member/events/member-activated.ts +++ b/packages/core/src/modules/member/events/member-activated.ts @@ -1,29 +1,40 @@ +import dayjs from 'dayjs'; + import { db } from '@oyster/db'; import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; import { job } from '@/infrastructure/bull/use-cases/job'; -import { ENV } from '@/shared/env'; +import { createGiftCard } from '@/modules/shopify'; export async function onMemberActivated({ studentId, }: GetBullJobData<'student.activated'>) { - const student = await db + const member = await db .selectFrom('students') - .select(['email', 'firstName', 'id']) + .select(['email', 'firstName', 'id', 'lastName']) .where('id', '=', studentId) .executeTakeFirstOrThrow(); - job('gamification.activity.completed', { - studentId: student.id, - type: 'get_activated', + const giftCardResult = await createGiftCard({ + expiresOn: dayjs().add(1, 'week').format('YYYY-MM-DD'), + initialValue: '50.00', + message: + 'Congratulations on becoming an activated ColorStack member! πŸŽ‰ ' + + 'From the team at ColorStack, we hope you enjoy your new merch! πŸ”₯', + note: 'This was awarded for member activation.', + recipient: { + email: member.email, + firstName: member.firstName, + lastName: member.lastName, + }, }); - job('notification.email.send', { - data: { - firstName: student.firstName, - studentProfileUrl: ENV.STUDENT_PROFILE_URL, - }, - name: 'student-activated', - to: student.email, + if (!giftCardResult.ok) { + throw new Error(giftCardResult.error); + } + + job('gamification.activity.completed', { + studentId: member.id, + type: 'get_activated', }); } diff --git a/packages/core/src/modules/notification/use-cases/send-email.ts b/packages/core/src/modules/notification/use-cases/send-email.ts index 7ce73bbe1..2fd85377b 100644 --- a/packages/core/src/modules/notification/use-cases/send-email.ts +++ b/packages/core/src/modules/notification/use-cases/send-email.ts @@ -11,7 +11,6 @@ import { ReferralAcceptedEmail, ReferralSentEmail, ResumeSubmittedEmail, - StudentActivatedEmail, StudentAttendedOnboardingEmail, StudentRemovedEmail, } from '@oyster/email-templates'; @@ -73,7 +72,6 @@ async function sendEmailWithPostmark(input: EmailTemplate) { .with('resume-submitted', () => FROM_NOTIFICATIONS) .with('referral-accepted', () => FROM_NOTIFICATIONS) .with('referral-sent', () => FROM_NOTIFICATIONS) - .with('student-activated', () => FROM_NOTIFICATIONS) .with('student-attended-onboarding', () => FROM_NOTIFICATIONS) .with('student-removed', () => FROM_NOTIFICATIONS) .exhaustive(); @@ -149,9 +147,6 @@ function getHtml(input: EmailTemplate): string { .with({ name: 'resume-submitted' }, ({ data }) => { return ResumeSubmittedEmail(data); }) - .with({ name: 'student-activated' }, ({ data }) => { - return StudentActivatedEmail(data); - }) .with({ name: 'student-attended-onboarding' }, ({ data }) => { return StudentAttendedOnboardingEmail(data); }) @@ -191,9 +186,6 @@ function getSubject(input: EmailTemplate): string { .with({ name: 'resume-submitted' }, ({ data }) => { return `Confirmation: ${data.resumeBookName} Resume Book! βœ…`; }) - .with({ name: 'student-activated' }, () => { - return 'Swag Pack 😜'; - }) .with({ name: 'student-attended-onboarding' }, () => { return "Onboarding Session, βœ…! What's Next?"; }) @@ -229,7 +221,6 @@ async function getAttachments( { name: 'referral-accepted' }, { name: 'referral-sent' }, { name: 'resume-submitted' }, - { name: 'student-activated' }, { name: 'student-removed' }, () => { return undefined; diff --git a/packages/core/src/modules/shopify.ts b/packages/core/src/modules/shopify.ts new file mode 100644 index 000000000..4b0eab804 --- /dev/null +++ b/packages/core/src/modules/shopify.ts @@ -0,0 +1,230 @@ +import { reportException } from '@/modules/sentry/use-cases/report-exception'; +import { fail, type Result, success } from '@/shared/utils/core.utils'; +import { RateLimiter } from '@/shared/utils/rate-limiter'; + +// Environment Variables + +const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN as string; +const SHOPIFY_STORE_NAME = process.env.SHOPIFY_STORE_NAME as string; + +// Constants + +const SHOPIFY_API_URL = `https://${SHOPIFY_STORE_NAME}.myshopify.com/admin/api/2024-07`; + +const SHOPIFY_HEADERS = { + 'Content-Type': 'application/json', + 'X-Shopify-Access-Token': SHOPIFY_ACCESS_TOKEN, +}; + +/** + * @see https://shopify.dev/docs/api/admin-rest#rate_limits + */ +const shopifyRateLimiter = new RateLimiter('shopify:requests', { + rateLimit: 2, + rateLimitWindow: 1, +}); + +// Core + +/** + * The Customer resource stores information about a shop's customers, such as + * their contact details, their order history, and whether they've agreed to + * receive email marketing. + * + * @see https://shopify.dev/docs/api/admin-rest/2024-07/resources/customer#resource-object + */ +type Customer = { + email: string; + firstName: string; + lastName: string; +}; + +// Customers + +type CreateCustomerResult = Result<{ id: number }>; + +/** + * Creates a new customer. + * + * @param customer - The customer to create. + * @returns A result object w/ the customer's ID, if successful. + * + * @see https://shopify.dev/docs/api/admin-rest/2024-07/resources/customer#post-customers + */ +async function getOrCreateCustomer( + customer: Customer +): Promise { + const customerResult = await getCustomerByEmail(customer.email); + + if (!customerResult.ok) { + return fail(customerResult); + } + + if (customerResult.data) { + return success({ id: customerResult.data.id }); + } + + const body = JSON.stringify({ + customer: { + email: customer.email, + first_name: customer.firstName, + last_name: customer.lastName, + }, + }); + + await shopifyRateLimiter.process(); + + const response = await fetch(SHOPIFY_API_URL + '/customers.json', { + body, + headers: SHOPIFY_HEADERS, + method: 'POST', + }); + + const data = await response.json(); + + if (!response.ok) { + const error = new Error('Failed to create Shopify customer.'); + + reportException(error, { + data, + status: response.status, + }); + + return fail({ + code: response.status, + error: error.message, + }); + } + + console.log('Shopify customer created!', data); + + const id = data.customer.id as number; + + return success({ id }); +} + +type GetCustomerByEmailResult = Result<{ id: number } | null>; + +/** + * Retrieves a Shopify customer by email. This uses the underlying "search" + * endpoint w/ a custom query based on email. + * + * @param email - The email of the customer to retrieve. + * @returns A result object with the customer's ID, if found. + * + * @see https://shopify.dev/docs/api/admin-rest/2024-07/resources/customer#get-customers-search + */ +async function getCustomerByEmail( + email: string +): Promise { + await shopifyRateLimiter.process(); + + const response = await fetch( + SHOPIFY_API_URL + `/customers/search.json?query=email:${email}`, + { + headers: SHOPIFY_HEADERS, + method: 'GET', + } + ); + + const data = await response.json(); + + if (!response.ok) { + const error = new Error('Failed to search for Shopify customer.'); + + reportException(error, { + data, + status: response.status, + }); + + return fail({ + code: response.status, + error: error.message, + }); + } + + const [customer] = data.customers as Array<{ id: number }>; + + if (!customer) { + return success(null); + } + + return success({ id: customer.id }); +} + +// Gift Cards + +/** + * A gift card is an alternative payment method. Each gift card has a unique + * code that is entered during checkout. Its balance can be redeemed over + * multiple checkouts. Optionally, a gift card can assigned to a specific + * customer. Gift card codes cannot be retrieved after they're createdβ€”only the + * last four characters can be retrieved. + * + * @see https://shopify.dev/docs/api/admin-rest/2024-07/resources/gift-card#resource-object + */ +type GiftCard = { + expiresOn: string; + initialValue: string; + message?: string; + note?: string; + recipient: Customer; +}; + +type CreateGiftCardResult = Result<{}>; + +/** + * Creates a new gift card and assigns it to a customer. + * + * @param card - The gift card to create. + * @returns A result indicating the success or failure of the operation. + * + * @see https://shopify.dev/docs/api/admin-rest/2024-07/resources/gift-card#post-gift-cards + */ +export async function createGiftCard( + card: GiftCard +): Promise { + const customerResult = await getOrCreateCustomer(card.recipient); + + if (!customerResult.ok) { + return fail(customerResult); + } + + const body = JSON.stringify({ + gift_card: { + expires_on: card.expiresOn, + initial_value: card.initialValue, + message: card.message, + note: card.note, + recipient_id: customerResult.data.id, + }, + }); + + await shopifyRateLimiter.process(); + + const response = await fetch(SHOPIFY_API_URL + '/gift_cards.json', { + body, + headers: SHOPIFY_HEADERS, + method: 'POST', + }); + + const data = await response.json(); + + if (!response.ok) { + const error = new Error('Failed to create Shopify gift card.'); + + reportException(error, { + data, + status: response.status, + }); + + return fail({ + code: response.status, + error: error.message, + }); + } + + console.log('Shopify gift card created!', data); + + return success({}); +} diff --git a/packages/core/src/modules/swag-pack/swag-pack.service.ts b/packages/core/src/modules/swag-pack/swag-pack.service.ts deleted file mode 100644 index 118732f86..000000000 --- a/packages/core/src/modules/swag-pack/swag-pack.service.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { z } from 'zod'; - -import { type Address } from '@oyster/types'; - -import { redis } from '@/infrastructure/redis'; -import { - OAuthTokenResponse, - type OAuthTokens, -} from '@/modules/authentication/oauth.service'; -import { ENV, IS_PRODUCTION } from '@/shared/env'; -import { ErrorWithContext } from '@/shared/errors'; -import { encodeBasicAuthenticationToken } from '@/shared/utils/auth.utils'; -import { validate } from '@/shared/utils/zod.utils'; - -// Errors - -class SwagUpApiError extends ErrorWithContext { - message = 'There was an issue fetching data from the SwagUp API.'; -} - -// Constants - -const SWAG_UP_API_URL = 'https://api.swagup.com/api/v1'; - -// Core - -type OrderSwagPackInput = { - contact: { - address: Address; - email: string; - firstName: string; - lastName: string; - }; -}; - -type SwagUpSendRequestBody = { - employee: { - first_name: string; - last_name: string; - email: string; - shipping_address1: string; - shipping_address2?: string; - shipping_city: string; - shipping_country: string; - shipping_state: string; - shipping_zip: string; - }; - force_address: boolean; - products: { - product: number; - sizes: { - quantity: number; - size: number; - }[]; - }[]; -}; - -const SwagPackOrder = z.object({ - id: z.number(), -}); - -class SwagPackOrderError extends ErrorWithContext { - message = 'There was an issue ordering a swag pack.'; -} - -export async function orderSwagPack(input: OrderSwagPackInput) { - if (!IS_PRODUCTION) { - return null; - } - - const { accessToken } = await retrieveTokens(); - - const { productId, sizeId } = await getProductInformation(); - - const body: SwagUpSendRequestBody = { - employee: { - email: input.contact.email, - first_name: input.contact.firstName, - last_name: input.contact.lastName, - shipping_address1: input.contact.address.line1, - shipping_address2: input.contact.address.line2, - shipping_city: input.contact.address.city, - shipping_country: input.contact.address.country, - shipping_state: input.contact.address.state, - shipping_zip: input.contact.address.zip, - }, - products: [ - { - product: productId, - sizes: [ - { - quantity: 1, - size: sizeId, - }, - ], - }, - ], - force_address: true, - }; - - const response = await fetch(`${SWAG_UP_API_URL}/employee-orders/`, { - body: JSON.stringify([body]), - method: 'post', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }); - - const data = await response.json(); - - if (!response.ok) { - throw new SwagPackOrderError().withContext({ - body, - error: data, - }); - } - - const [order] = validate(SwagPackOrder.array(), data); - - console.log({ - code: 'swag_pack_ordered', - message: 'Swag pack was ordered.', - data: { - email: input.contact.email, - firstName: input.contact.firstName, - lastName: input.contact.lastName, - orderId: order.id, - }, - }); - - return order.id.toString(); -} - -const SwagProduct = z.object({ - stock: z.object({ quantity: z.coerce.number() }).array(), -}); - -export async function getSwagPackInventory() { - const { productId } = await getProductInformation(); - - const { accessToken } = await retrieveTokens(); - - const response = await fetch( - `${SWAG_UP_API_URL}/account-products/${productId}/`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - - if (!response.ok) { - throw new SwagUpApiError().withContext({ productId }); - } - - const product = validate(SwagProduct, await response.json()); - - const inventory = product.stock[0].quantity || 0; - - return inventory; -} - -const SwagProductInformation = z.object({ - productId: z.coerce.number(), - sizeId: z.coerce.number(), -}); - -async function getProductInformation() { - const [productId, sizeId] = await Promise.all([ - redis.get('swag_up:product_id'), - redis.get('swag_up:size_id'), - ]); - - const result = SwagProductInformation.safeParse({ - productId, - sizeId, - }); - - if (!result.success) { - throw new Error( - 'SwagUp information was either not found or misformatted in Redis.' - ); - } - - return result.data; -} - -// Authentication - -async function retrieveTokens(): Promise { - const [accessToken = '', refreshToken = ''] = await Promise.all([ - redis.get('swag_up:access_token'), - redis.get('swag_up:refresh_token'), - ]); - - if (!accessToken || !refreshToken) { - throw new Error( - 'There was some token(s) not found. Please reauthenticate via the SwagUp OAuth 2.0 flow.' - ); - } - - // This is just hitting a dummy endpoint on the SwagUp API to ensure that - // the access token is working properly (not expired, etc). Ideally, - // SwagUp would have an endpoint like POST /token/test to know if the - // token needed to be refreshed or not, but this is our current - // workaround. - const response = await fetch(`${SWAG_UP_API_URL}/accounts?limit=1`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (response.ok) { - return { - accessToken, - refreshToken, - }; - } - - const { access_token: newAccessToken, refresh_token: newRefreshToken } = - await refreshAuthentication(refreshToken); - - await Promise.all([ - redis.set('swag_up:access_token', newAccessToken), - redis.set('swag_up:refresh_token', newRefreshToken), - ]); - - return { - accessToken: newAccessToken, - refreshToken: newRefreshToken, - }; -} - -async function refreshAuthentication( - refreshToken: string -): Promise { - const url = new URL('https://signin.swagup.com/oauth2/default/v1/token'); - - url.searchParams.set('grant_type', 'refresh_token'); - url.searchParams.set('refresh_token', refreshToken); - - const basicToken = encodeBasicAuthenticationToken( - ENV.SWAG_UP_CLIENT_ID, - ENV.SWAG_UP_CLIENT_SECRET - ); - - const response = await fetch(url, { - method: 'post', - headers: { - Accept: 'application/json', - Authorization: `Basic ${basicToken}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); - - const data = validate(OAuthTokenResponse, await response.json()); - - return data; -} diff --git a/packages/core/src/modules/swag-pack/swag-pack.types.ts b/packages/core/src/modules/swag-pack/swag-pack.types.ts deleted file mode 100644 index 3a3e85ece..000000000 --- a/packages/core/src/modules/swag-pack/swag-pack.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod'; - -import { Address, Student } from '@oyster/types'; - -export const ClaimSwagPackInput = z.object({ - addressCity: Address.shape.city, - addressCountry: Address.shape.country, - addressLine1: Address.shape.line1, - addressLine2: Address.shape.line2, - addressState: Address.shape.state, - addressZip: Address.shape.zip, - studentId: Student.shape.id, -}); - -export type ClaimSwagPackInput = z.infer; diff --git a/packages/core/src/modules/swag-pack/swag-pack.worker.ts b/packages/core/src/modules/swag-pack/swag-pack.worker.ts deleted file mode 100644 index 7c6e2f38f..000000000 --- a/packages/core/src/modules/swag-pack/swag-pack.worker.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { match } from 'ts-pattern'; - -import { SwagPackBullJob } from '@/infrastructure/bull/bull.types'; -import { registerWorker } from '@/infrastructure/bull/use-cases/register-worker'; -import { notifySwagPackInventory } from './use-cases/notify-swag-pack-inventory'; - -export const swagPackWorker = registerWorker( - 'swag_pack', - SwagPackBullJob, - async (job) => { - return match(job) - .with({ name: 'swag_pack.inventory.notify' }, ({ data }) => { - return notifySwagPackInventory(data); - }) - .exhaustive(); - } -); diff --git a/packages/core/src/modules/swag-pack/use-cases/claim-swag-pack.ts b/packages/core/src/modules/swag-pack/use-cases/claim-swag-pack.ts deleted file mode 100644 index 6c46827af..000000000 --- a/packages/core/src/modules/swag-pack/use-cases/claim-swag-pack.ts +++ /dev/null @@ -1,91 +0,0 @@ -import dedent from 'dedent'; - -import { db } from '@oyster/db'; - -import { job } from '@/infrastructure/bull/use-cases/job'; -import { fail, type Result, success } from '@/shared/utils/core.utils'; -import { orderSwagPack } from '../swag-pack.service'; -import { type ClaimSwagPackInput } from '../swag-pack.types'; - -export async function claimSwagPack({ - addressCity, - addressCountry, - addressLine1, - addressLine2, - addressState, - addressZip, - studentId, -}: ClaimSwagPackInput): Promise { - // We save the address regardless if the swag pack order failed or not so - // we'll be able to send them something in the future. - const student = await db - .updateTable('students') - .set({ - addressCity, - addressCountry, - addressLine1, - addressLine2, - addressState, - addressZip, - }) - .where('id', '=', studentId) - .returning(['email', 'firstName', 'lastName']) - .executeTakeFirstOrThrow(); - - // Currently, SwagUp only supports the US, but not Puerto Rico. - // See: https://support.swagup.com/en/articles/6952397-international-shipments-restricted-items - const isAddressSupported = addressCountry === 'US' && addressState !== 'PR'; - - // If the address isn't supported, then we'll send a notification to our - // team to create a gift card manually for them. - if (!isAddressSupported) { - const notification = dedent` - ${student.firstName} ${student.lastName} (${student.email}) is attempting to claim a swag pack, but they're either from Puerto Rico or Canada, which is not supported for our product. - - We let them know we'll send them a merch store gift card in the next "few days"! - `; - - job('notification.slack.send', { - message: notification, - workspace: 'internal', - }); - - const error = dedent` - Unfortunately, our swag pack provider, SwagUp, does not support shipments to Puerto Rico and Canada. Instead, we will send you a gift card to our official merch store. - - Our team has been notified, please give us a few days to complete this request! - `; - - return fail({ - code: 400, - error, - }); - } - - const swagPackOrderId = await orderSwagPack({ - contact: { - address: { - city: addressCity, - country: addressCountry, - line1: addressLine1, - line2: addressLine2, - state: addressState, - zip: addressZip, - }, - email: student.email, - firstName: student.firstName, - lastName: student.lastName, - }, - }); - - await db - .updateTable('students') - .set({ - claimedSwagPackAt: new Date(), - swagUpOrderId: swagPackOrderId, - }) - .where('id', '=', studentId) - .execute(); - - return success({}); -} diff --git a/packages/core/src/modules/swag-pack/use-cases/notify-swag-pack-inventory.ts b/packages/core/src/modules/swag-pack/use-cases/notify-swag-pack-inventory.ts deleted file mode 100644 index 1ef82a0e4..000000000 --- a/packages/core/src/modules/swag-pack/use-cases/notify-swag-pack-inventory.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; -import { job } from '@/infrastructure/bull/use-cases/job'; -import { getSwagPackInventory } from '../swag-pack.service'; - -export async function notifySwagPackInventory( - _: GetBullJobData<'swag_pack.inventory.notify'> -) { - const inventory = await getSwagPackInventory(); - - job('notification.slack.send', { - message: `Our current SwagUp inventory is: *${inventory}*`, - workspace: 'internal', - }); -} diff --git a/packages/core/src/shared/env.ts b/packages/core/src/shared/env.ts index 0099114e6..bb265c490 100644 --- a/packages/core/src/shared/env.ts +++ b/packages/core/src/shared/env.ts @@ -33,8 +33,6 @@ export const ENV = { .SLACK_INTRODUCTIONS_CHANNEL_ID as string, SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET as string, STUDENT_PROFILE_URL: process.env.STUDENT_PROFILE_URL as string, - SWAG_UP_CLIENT_ID: process.env.SWAG_UP_CLIENT_ID as string, - SWAG_UP_CLIENT_SECRET: process.env.SWAG_UP_CLIENT_SECRET as string, }; // TODO: Below are the only variables that we need to process in the core, diff --git a/packages/email-templates/emails/student-activated.tsx b/packages/email-templates/emails/student-activated.tsx deleted file mode 100644 index c1f07f51d..000000000 --- a/packages/email-templates/emails/student-activated.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; - -import { Email } from './components/email'; -import { type EmailTemplateData } from '../src/types'; - -export function StudentActivatedEmail({ - firstName, - studentProfileUrl, -}: EmailTemplateData<'student-activated'>) { - return ( - - - Congratulations, you are now an activated ColorStack member! πŸŽ‰ It's - time to claim your swag pack! 🎁 Here is how you can do so: - - - - Hi {firstName}, - - - Congratulations on becoming an activated ColorStack member! You've - shown your dedication to the community and we couldn't be more - grateful. It's time for you to claim your ColorStack swag pack! - 🎁 -
-
- You'll need to do the following to claim your swag pack: -
- -
    -
  1. - Click the link below. -
  2. -
  3. - - Enter your email address to receive a one-time passcode. - -
  4. -
  5. - - Enter the six-digit passcode that we sent to your email. - -
  6. -
  7. - - Submit your mailing address for the swag pack. πŸŽ‰ - -
  8. -
- - - Claim Swag Pack - - - -
-
- ); -} - -export default StudentActivatedEmail; diff --git a/packages/email-templates/src/index.ts b/packages/email-templates/src/index.ts index 2ef82f588..1e1b32ce1 100644 --- a/packages/email-templates/src/index.ts +++ b/packages/email-templates/src/index.ts @@ -6,7 +6,6 @@ export { PrimaryEmailChangedEmail } from '../emails/primary-email-changed'; export { ReferralAcceptedEmail } from '../emails/referral-accepted'; export { ReferralSentEmail } from '../emails/referral-sent'; export { ResumeSubmittedEmail } from '../emails/resume-submitted'; -export { StudentActivatedEmail } from '../emails/student-activated'; export { StudentAttendedOnboardingEmail } from '../emails/student-attended-onboarding'; export { StudentRemovedEmail } from '../emails/student-removed'; export { EmailTemplate } from './types'; diff --git a/packages/email-templates/src/types.ts b/packages/email-templates/src/types.ts index 00b0dc504..182ea1c3c 100644 --- a/packages/email-templates/src/types.ts +++ b/packages/email-templates/src/types.ts @@ -64,13 +64,6 @@ export const EmailTemplate = z.discriminatedUnion('name', [ resumeBookUri: z.string().url(), }), }), - BaseEmail.extend({ - name: z.literal('student-activated'), - data: z.object({ - firstName: Student.shape.firstName, - studentProfileUrl: z.string().url(), - }), - }), BaseEmail.extend({ name: z.literal('student-attended-onboarding'), data: z.object({ diff --git a/packages/types/src/domain/student.ts b/packages/types/src/domain/student.ts index 52fe4dd4a..b64380661 100644 --- a/packages/types/src/domain/student.ts +++ b/packages/types/src/domain/student.ts @@ -12,7 +12,7 @@ import { } from './types'; import { normalizeUri } from '../../../utils/src/index'; import { type ExtractValue } from '../shared/types'; -import { NullishString } from '../shared/zod'; +import { EmptyStringToNull, NullishString } from '../shared/zod'; // Enums @@ -172,7 +172,7 @@ export const Student = Entity.merge(StudentSocialLinks) .string() .trim() .regex(/^\d{10}$/, 'Must be a 10-digit number.') - .optional(), + .or(EmptyStringToNull), /** * The preferred name that a member would like to go by. This will typically @@ -208,7 +208,6 @@ export const Student = Entity.merge(StudentSocialLinks) }), slackId: z.string().optional(), - swagUpOrderId: z.string().min(1).optional(), type: z.nativeEnum(MemberType), /** diff --git a/packages/types/src/shared/zod.ts b/packages/types/src/shared/zod.ts index bdb21a101..5b254f672 100644 --- a/packages/types/src/shared/zod.ts +++ b/packages/types/src/shared/zod.ts @@ -4,6 +4,8 @@ export const BooleanInput = z.preprocess((value) => { return typeof value === 'boolean' ? value : value === '1'; }, z.boolean()); +export const EmptyStringToNull = z.literal('').transform(() => null); + export const ISO8601Date = z.coerce.date().transform((value) => { return value.toISOString().split('T')[0]; });