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:
-
-
-
- -
- Click the link below.
-
- -
-
- Enter your email address to receive a one-time passcode.
-
-
- -
-
- Enter the six-digit passcode that we sent to your email.
-
-
- -
-
- Submit your mailing address for the swag pack. π
-
-
-
-
-
- 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({