();
-
- 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:
-
-
-
- -
- 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 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];
});