diff --git a/apps/api/.env.example b/apps/api/.env.example index 91edc761..384b6e5d 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= diff --git a/apps/api/src/shared/env.ts b/apps/api/src/shared/env.ts index a5ed001d..9a875f17 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, @@ -84,6 +86,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, 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 index 9a55acc4..09d92119 100644 --- 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 @@ -99,23 +99,32 @@ export default function ClaimSwagPack() { return ( <> - - Claim Your Swag Pack 🎁 - - - }> {(inventory) => { return inventory > 0 ? ( - + <> + + Claim Your Swag Pack 🎁 + + + + + ) : ( - - 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! - + <> + + + Sit tight, we're sending you a gift card! πŸ€‘ + + + + + + We're changing the way we send out swag. Give us 2 business + days and we'll send you a gift card to our Merch Store! + + ); }} diff --git a/apps/member-profile/app/routes/_profile.home.tsx b/apps/member-profile/app/routes/_profile.home.tsx index 70160a18..35fe05e9 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 ( diff --git a/packages/core/package.json b/packages/core/package.json index 3e18ec55..80ed4fef 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,7 +29,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/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 fb803cbe..669b8b9a 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 ! 🎁 + You can now claim your free ColorStack merch ! 🎁 `; } 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 1072fc27..21777e3d 100644 --- a/packages/core/src/modules/member/events/member-activated.ts +++ b/packages/core/src/modules/member/events/member-activated.ts @@ -2,7 +2,6 @@ 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'; export async function onMemberActivated({ studentId, @@ -17,13 +16,4 @@ export async function onMemberActivated({ studentId: student.id, type: 'get_activated', }); - - job('notification.email.send', { - data: { - firstName: student.firstName, - studentProfileUrl: ENV.STUDENT_PROFILE_URL, - }, - name: 'student-activated', - to: student.email, - }); } 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 7ce73bbe..2fd85377 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 00000000..4b0eab80 --- /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/email-templates/emails/student-activated.tsx b/packages/email-templates/emails/student-activated.tsx deleted file mode 100644 index c1f07f51..00000000 --- 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 2ef82f58..1e1b32ce 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 00b0dc50..182ea1c3 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({