feat: scaffold Phase 0 — Remix app template, CI, Redis/BullMQ, test harness

Bootstraps from Shopify's official shopify-app-template-remix (cloned
directly rather than via `shopify app init`, which requires an interactive
Partner login unavailable in this session):

- Prisma (SQLite dev / Postgres-ready) with baseline Session model + migration
- Vitest configured for unit tests, Playwright configured for E2E
- Redis client + BullMQ worker skeleton (app/lib/redis.server.ts, jobs/worker.ts)
- shopify.app.toml: minimal scopes, GDPR + orders webhooks wired (stub handlers),
  app proxy config for the future storefront widget
- Stripped template-repo-only meta files (CLA, issue templates, demo product
  page) and replaced CI with a lint+typecheck+test workflow
- Bumped @shopify/shopify-app-session-storage-prisma to resolve a duplicate
  @shopify/shopify-api install that broke typecheck
- Dropped the Jest-only ESLint config (template default) since the project
  standardizes on Vitest per IMPLEMENTATION_PLAN.md

Verified: npm install, lint, typecheck, unit tests, prisma migrate dev, and
npm run build all pass on Node 22 LTS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-08-23 15:45:28 -04:00
parent 952d274bea
commit 0303eba07a
53 changed files with 19471 additions and 0 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
.cache
build
node_modules

15
.editorconfig Normal file
View File

@ -0,0 +1,15 @@
# editorconfig.org
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
# Markdown syntax specifies that trailing whitespaces can be meaningful,
# so lets not trim those. e.g. 2 trailing spaces = linebreak (<br />)
# See https://daringfireball.net/projects/markdown/syntax#p
[*.md]
trim_trailing_whitespace = false

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
# Shopify app credentials (from `shopify app config link` / Partner Dashboard).
# Never commit the real .env — this file is the template only.
SHOPIFY_API_KEY=
SHOPIFY_API_SECRET=
SCOPES=read_products,read_customers,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales
SHOPIFY_APP_URL=https://replace-with-your-tunnel-url.example.com
SHOP_CUSTOM_DOMAIN=
# App DB (Prisma). SQLite locally, Postgres in staging/prod.
DATABASE_URL="file:dev.sqlite"
# Redis (slot-holds TTL, BullMQ queues).
REDIS_URL=redis://127.0.0.1:6379
# Google Maps (Phase 5 — geocoding, radius/distance eligibility, map display).
GOOGLE_MAPS_API_KEY=

6
.eslintignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
build
public/build
shopify-app-remix
*/*.yml
.shopify

8
.eslintrc.cjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('@types/eslint').Linter.BaseConfig} */
module.exports = {
root: true,
extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node", "prettier"],
globals: {
shopify: "readonly"
},
};

24
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: CI
on: [push, pull_request]
jobs:
test:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Unit tests
run: npm test -- --run

28
.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
node_modules
.DS_Store
/.cache
/build
/app/build
/public/build/
/public/_dev
/app/public/build
/prisma/dev.sqlite
/prisma/dev.sqlite-journal
database.sqlite
.env
.env.*
!.env.example
yarn.lock
pnpm-lock.yaml
/extensions/*/dist
# Redis dump
dump.rdb
# Ignore shopify files created during app dev
.shopify/*
.shopify.lock

45
.graphqlrc.ts Normal file
View File

@ -0,0 +1,45 @@
import fs from "fs";
import { ApiVersion } from "@shopify/shopify-api";
import { shopifyApiProject, ApiType } from "@shopify/api-codegen-preset";
import type { IGraphQLConfig } from "graphql-config";
function getConfig() {
const config: IGraphQLConfig = {
projects: {
default: shopifyApiProject({
apiType: ApiType.Admin,
apiVersion: ApiVersion.July25,
documents: [
"./app/**/*.{js,ts,jsx,tsx}",
"./app/.server/**/*.{js,ts,jsx,tsx}",
],
outputDir: "./app/types",
}),
},
};
let extensions: string[] = [];
try {
extensions = fs.readdirSync("./extensions");
} catch {
// ignore if no extensions
}
for (const entry of extensions) {
const extensionPath = `./extensions/${entry}`;
const schema = `${extensionPath}/schema.graphql`;
if (!fs.existsSync(schema)) {
continue;
}
config.projects[entry] = {
schema,
documents: [`${extensionPath}/**/*.graphql`],
};
}
return config;
}
const config = getConfig();
export default config;

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
engine-strict=true
@shopify:registry=https://registry.npmjs.org

7
.prettierignore Normal file
View File

@ -0,0 +1,7 @@
package.json
.shadowenv.d
.vscode
node_modules
prisma
public
.shopify

6
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"graphql.vscode-graphql",
"shopify.polaris-for-vscode",
]
}

8
.vscode/mcp.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"servers": {
"shopify-dev-mcp": {
"command": "npx",
"args": ["-y", "@shopify/dev-mcp@latest"]
}
}
}

5
AGENTS.md Normal file
View File

@ -0,0 +1,5 @@
# Shopify app development
This app is scaffolded from a Shopify app template. See the README for framework-specific details.
Use the [Shopify AI Toolkit](https://shopify.dev/docs/apps/build/ai-toolkit) for all Shopify API and platform work. If missing, install it in the agent host per that page (or `npx skills add Shopify/shopify-ai-toolkit --list` for skill-compatible hosts) — do not add tooling to this repo.

94
CHANGELOG.md Normal file
View File

@ -0,0 +1,94 @@
# @shopify/shopify-app-template-remix
## 2025.12.11
- [#1201](https://github.com/Shopify/shopify-app-template-remix/pull/1201) Update `@shopify/shopify-app-remix` to v4.1.0 and `@shopify/shopify-app-session-storage-prisma` to v8.0.0, add refresh token fields (`refreshToken` and `refreshTokenExpires`) to Session model in Prisma schema, and adopt the `expiringOfflineAccessTokens` flag for enhanced security through token rotation. See [expiring vs non-expiring offline tokens](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens#expiring-vs-non-expiring-offline-tokens) for more information.
## 2025.10.01
**Remix is now React Router.** As of [React Router v7](https://remix.run/blog/merging-remix-and-react-router), Remix and React Router have merged.
For new projects, use the **[Shopify App Template - React Router](https://github.com/Shopify/shopify-app-template-react-router)** instead.
To migrate your existing Remix app, follow the **[migration guide](https://github.com/Shopify/shopify-app-template-react-router/wiki/Upgrading-from-Remix)**.
## 2025.08.16
- [#52](https://github.com/Shopify/shopify-app-template-remix/pull/1153) Use `ApiVersion.July25` rather than `LATEST_API_VERSION` in `.graphqlrc`.
## 2025.07.07
- [#1103](https://github.com/Shopify/shopify-app-template-remix/pull/1086) Remove deprecated .npmrc config values
## 2025.06.12
- [#1075](https://github.com/Shopify/shopify-app-template-remix/pull/1075) Add Shopify MCP to [VSCode configs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_enable-mcp-support-in-vs-code)
## 2025.06.12
-[#1082](https://github.com/Shopify/shopify-app-template-remix/pull/1082) Remove local Shopify CLI from the template. Developers should use the Shopify CLI [installed globally](https://shopify.dev/docs/api/shopify-cli#installation).
## 2025.03.18
-[#998](https://github.com/Shopify/shopify-app-template-remix/pull/998) Update to Vite 6
## 2025.03.01
- [#982](https://github.com/Shopify/shopify-app-template-remix/pull/982) Add Shopify Dev Assistant extension to the VSCode extension recommendations
## 2025.01.31
- [#952](https://github.com/Shopify/shopify-app-template-remix/pull/952) Update to Shopify App API v2025-01
## 2025.01.23
- [#923](https://github.com/Shopify/shopify-app-template-remix/pull/923) Update `@shopify/shopify-app-session-storage-prisma` to v6.0.0
## 2025.01.8
- [#923](https://github.com/Shopify/shopify-app-template-remix/pull/923) Enable GraphQL autocomplete for Javascript
## 2024.12.19
- [#904](https://github.com/Shopify/shopify-app-template-remix/pull/904) bump `@shopify/app-bridge-react` to latest
-
## 2024.12.18
- [875](https://github.com/Shopify/shopify-app-template-remix/pull/875) Add Scopes Update Webhook
## 2024.12.05
- [#910](https://github.com/Shopify/shopify-app-template-remix/pull/910) Install `openssl` in Docker image to fix Prisma (see [#25817](https://github.com/prisma/prisma/issues/25817#issuecomment-2538544254))
- [#907](https://github.com/Shopify/shopify-app-template-remix/pull/907) Move `@remix-run/fs-routes` to `dependencies` to fix Docker image build
- [#899](https://github.com/Shopify/shopify-app-template-remix/pull/899) Disable v3_singleFetch flag
- [#898](https://github.com/Shopify/shopify-app-template-remix/pull/898) Enable the `removeRest` future flag so new apps aren't tempted to use the REST Admin API.
## 2024.12.04
- [#891](https://github.com/Shopify/shopify-app-template-remix/pull/891) Enable remix future flags.
## 2024.11.26
- [888](https://github.com/Shopify/shopify-app-template-remix/pull/888) Update restResources version to 2024-10
## 2024.11.06
- [881](https://github.com/Shopify/shopify-app-template-remix/pull/881) Update to the productCreate mutation to use the new ProductCreateInput type
## 2024.10.29
- [876](https://github.com/Shopify/shopify-app-template-remix/pull/876) Update shopify-app-remix to v3.4.0 and shopify-app-session-storage-prisma to v5.1.5
## 2024.10.02
- [863](https://github.com/Shopify/shopify-app-template-remix/pull/863) Update to Shopify App API v2024-10 and shopify-app-remix v3.3.2
## 2024.09.18
- [850](https://github.com/Shopify/shopify-app-template-remix/pull/850) Removed "~" import alias
## 2024.09.17
- [842](https://github.com/Shopify/shopify-app-template-remix/pull/842) Move webhook processing to individual routes
## 2024.08.19
Replaced deprecated `productVariantUpdate` with `productVariantsBulkUpdate`
## v2024.08.06
Allow `SHOP_REDACT` webhook to process without admin context
## v2024.07.16
Started tracking changes and releases using calver

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM node:18-alpine
RUN apk add --no-cache openssl
EXPOSE 3000
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev && npm cache clean --force
# Remove CLI packages since we don't need them in production by default.
# Remove this line if you want to run CLI commands in your container.
RUN npm remove @shopify/cli
COPY . .
RUN npm run build
CMD ["npm", "run", "docker-start"]

43
README.md Normal file
View File

@ -0,0 +1,43 @@
# Delivery Date & Time
Shopify app: scheduling for Shipping / Local Delivery / Store Pickup, with
date-time slots, capacity intelligence, and **all-plan checkout enforcement**
via Cart/Checkout Validation Functions.
Read **[PRODUCT_STRATEGY.md](./PRODUCT_STRATEGY.md)** (why) and
**[IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md)** (how, phased build
order) before making changes. [CLAUDE.md](./CLAUDE.md) holds the
non-negotiables for AI-assisted work in this repo.
## Getting started
```sh
npm install
cp .env.example .env # fill in Shopify app credentials after linking
npx prisma migrate dev # creates prisma/dev.sqlite locally
npm run dev # shopify app dev — requires `shopify auth login` first
```
Redis must be running locally for slot-holds/BullMQ (Phase 4+):
```sh
redis-server # or: docker run -p 6379:6379 redis
npm run worker # BullMQ worker (jobs/worker.ts)
```
## Commands
| Command | Purpose |
|---|---|
| `npm run dev` | `shopify app dev` — local dev against a dev store |
| `npm test` | Vitest unit tests |
| `npm run test:e2e` | Playwright E2E |
| `npm run lint` / `npm run typecheck` | ESLint / `tsc --noEmit` |
| `npx prisma migrate dev` | DB migrations |
| `npm run deploy` | `shopify app deploy` — deploy extensions/functions |
| `npm run worker` | BullMQ worker (hold-expiry, notifications) |
## Status
Greenfield — Phase 0 (scaffold & CI) complete. See §6 of
`IMPLEMENTATION_PLAN.md` for the phased build order and acceptance criteria.

15
app/db.server.ts Normal file
View File

@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prismaGlobal: PrismaClient;
}
if (process.env.NODE_ENV !== "production") {
if (!global.prismaGlobal) {
global.prismaGlobal = new PrismaClient();
}
}
const prisma = global.prismaGlobal ?? new PrismaClient();
export default prisma;

59
app/entry.server.tsx Normal file
View File

@ -0,0 +1,59 @@
import { PassThrough } from "stream";
import { renderToPipeableStream } from "react-dom/server";
import { RemixServer } from "@remix-run/react";
import {
createReadableStreamFromReadable,
type EntryContext,
} from "@remix-run/node";
import { isbot } from "isbot";
import { addDocumentResponseHeaders } from "./shopify.server";
export const streamTimeout = 5000;
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
addDocumentResponseHeaders(request, responseHeaders);
const userAgent = request.headers.get("user-agent");
const callbackName = isbot(userAgent ?? '')
? "onAllReady"
: "onShellReady";
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
/>,
{
[callbackName]: () => {
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error) {
reject(error);
},
onError(error) {
responseStatusCode = 500;
console.error(error);
},
}
);
// Automatically timeout the React renderer after 6 seconds, which ensures
// React has enough time to flush down the rejected boundary contents
setTimeout(abort, streamTimeout + 1000);
});
}

1
app/globals.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "*.css";

20
app/lib/redis.server.ts Normal file
View File

@ -0,0 +1,20 @@
import { Redis } from "ioredis";
declare global {
// eslint-disable-next-line no-var
var __redis: Redis | undefined;
}
// One shared connection per process (Remix dev reloads the module, so we
// cache it on `global` to avoid exhausting Redis connections).
export const redis =
global.__redis ??
new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
if (process.env.NODE_ENV !== "production") {
global.__redis = redis;
}
export default redis;

30
app/root.tsx Normal file
View File

@ -0,0 +1,30 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
export default function App() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="preconnect" href="https://cdn.shopify.com/" />
<link
rel="stylesheet"
href="https://cdn.shopify.com/static/fonts/inter/v4/styles.css"
/>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

3
app/routes.ts Normal file
View File

@ -0,0 +1,3 @@
import { flatRoutes } from "@remix-run/fs-routes";
export default flatRoutes();

View File

@ -0,0 +1,58 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { login } from "../../shopify.server";
import styles from "./styles.module.css";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
if (url.searchParams.get("shop")) {
throw redirect(`/app?${url.searchParams.toString()}`);
}
return { showForm: Boolean(login) };
};
export default function App() {
const { showForm } = useLoaderData<typeof loader>();
return (
<div className={styles.index}>
<div className={styles.content}>
<h1 className={styles.heading}>A short heading about [your app]</h1>
<p className={styles.text}>
A tagline about [your app] that describes your value proposition.
</p>
{showForm && (
<Form className={styles.form} method="post" action="/auth/login">
<label className={styles.label}>
<span>Shop domain</span>
<input className={styles.input} type="text" name="shop" />
<span>e.g: my-shop-domain.myshopify.com</span>
</label>
<button className={styles.button} type="submit">
Log in
</button>
</Form>
)}
<ul className={styles.list}>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
<li>
<strong>Product feature</strong>. Some detail about your feature and
its benefit to your customer.
</li>
</ul>
</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
.index {
align-items: center;
display: flex;
justify-content: center;
height: 100%;
width: 100%;
text-align: center;
padding: 1rem;
}
.heading,
.text {
padding: 0;
margin: 0;
}
.text {
font-size: 1.2rem;
padding-bottom: 2rem;
}
.content {
display: grid;
gap: 2rem;
}
.form {
display: flex;
align-items: center;
justify-content: flex-start;
margin: 0 auto;
gap: 1rem;
}
.label {
display: grid;
gap: 0.2rem;
max-width: 20rem;
text-align: left;
font-size: 1rem;
}
.input {
padding: 0.4rem;
}
.button {
padding: 0.4rem;
}
.list {
list-style: none;
padding: 0;
padding-top: 3rem;
margin: 0;
display: flex;
gap: 2rem;
}
.list > li {
max-width: 20rem;
text-align: left;
}
@media only screen and (max-width: 50rem) {
.list {
display: block;
}
.list > li {
padding-bottom: 1rem;
}
}

36
app/routes/app._index.tsx Normal file
View File

@ -0,0 +1,36 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { Page, Layout, Text, Card, BlockStack, EmptyState } from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return null;
};
export default function Index() {
return (
<Page>
<TitleBar title="Delivery Date & Time" />
<BlockStack gap="500">
<Layout>
<Layout.Section>
<Card>
<EmptyState
heading="No locations configured yet"
action={{ content: "Add a location", url: "/app/locations" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p" variant="bodyMd">
Set up a fulfillment location and its weekly slots to start
taking scheduled Shipping, Local Delivery, or Pickup orders.
</Text>
</EmptyState>
</Card>
</Layout.Section>
</Layout>
</BlockStack>
</Page>
);
}

40
app/routes/app.tsx Normal file
View File

@ -0,0 +1,40 @@
import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node";
import { Link, Outlet, useLoaderData, useRouteError } from "@remix-run/react";
import { boundary } from "@shopify/shopify-app-remix/server";
import { AppProvider } from "@shopify/shopify-app-remix/react";
import { NavMenu } from "@shopify/app-bridge-react";
import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
import { authenticate } from "../shopify.server";
export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return { apiKey: process.env.SHOPIFY_API_KEY || "" };
};
export default function App() {
const { apiKey } = useLoaderData<typeof loader>();
return (
<AppProvider isEmbeddedApp apiKey={apiKey}>
<NavMenu>
<Link to="/app" rel="home">
Home
</Link>
</NavMenu>
<Outlet />
</AppProvider>
);
}
// Shopify needs Remix to catch some thrown responses, so that their headers are included in the response.
export function ErrorBoundary() {
return boundary.error(useRouteError());
}
export const headers: HeadersFunction = (headersArgs) => {
return boundary.headers(headersArgs);
};

8
app/routes/auth.$.tsx Normal file
View File

@ -0,0 +1,8 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticate.admin(request);
return null;
};

View File

@ -0,0 +1,16 @@
import type { LoginError } from "@shopify/shopify-app-remix/server";
import { LoginErrorType } from "@shopify/shopify-app-remix/server";
interface LoginErrorMessage {
shop?: string;
}
export function loginErrorMessage(loginErrors: LoginError): LoginErrorMessage {
if (loginErrors?.shop === LoginErrorType.MissingShop) {
return { shop: "Please enter your shop domain to log in" };
} else if (loginErrors?.shop === LoginErrorType.InvalidShop) {
return { shop: "Please enter a valid shop domain to log in" };
}
return {};
}

View File

@ -0,0 +1,68 @@
import { useState } from "react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useLoaderData } from "@remix-run/react";
import {
AppProvider as PolarisAppProvider,
Button,
Card,
FormLayout,
Page,
Text,
TextField,
} from "@shopify/polaris";
import polarisTranslations from "@shopify/polaris/locales/en.json";
import polarisStyles from "@shopify/polaris/build/esm/styles.css?url";
import { login } from "../../shopify.server";
import { loginErrorMessage } from "./error.server";
export const links = () => [{ rel: "stylesheet", href: polarisStyles }];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const errors = loginErrorMessage(await login(request));
return { errors, polarisTranslations };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const errors = loginErrorMessage(await login(request));
return {
errors,
};
};
export default function Auth() {
const loaderData = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [shop, setShop] = useState("");
const { errors } = actionData || loaderData;
return (
<PolarisAppProvider i18n={loaderData.polarisTranslations}>
<Page>
<Card>
<Form method="post">
<FormLayout>
<Text variant="headingMd" as="h2">
Log in
</Text>
<TextField
type="text"
name="shop"
label="Shop domain"
helpText="example.myshopify.com"
value={shop}
onChange={setShop}
autoComplete="on"
error={errors.shop}
/>
<Button submit>Log in</Button>
</FormLayout>
</Form>
</Card>
</Page>
</PolarisAppProvider>
);
}

View File

@ -0,0 +1,21 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { payload, session, topic, shop } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
const current = payload.current as string[];
if (session) {
await db.session.update({
where: {
id: session.id
},
data: {
scope: current.toString(),
},
});
}
return new Response();
};

View File

@ -0,0 +1,17 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
import db from "../db.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, session, topic } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`);
// Webhook requests can trigger multiple times and after an app has already been uninstalled.
// If this webhook already ran, the session may have been deleted previously.
if (session) {
await db.session.deleteMany({ where: { shop } });
}
return new Response();
};

View File

@ -0,0 +1,13 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
// GDPR: a customer (or Shopify on their behalf) requesting their data.
// TODO (Phase 8): compile and return the shop's stored Booking/Waitlist/
// Reschedule/Notification records for this customer.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`, payload);
return new Response();
};

View File

@ -0,0 +1,14 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
// GDPR: erase a specific customer's data (fires 10 days after a data
// erasure request, or 6 months after their last order on the shop).
// TODO (Phase 8): purge/anonymize Booking.customerEmail/customerPhone,
// Waitlist entries, and Notification payloads for this customer.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`, payload);
return new Response();
};

View File

@ -0,0 +1,12 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
// TODO (Phase 4): mark the linked Booking cancelled and free its
// capacity/resources. See IMPLEMENTATION_PLAN.md §5.2.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`, payload);
return new Response();
};

View File

@ -0,0 +1,13 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
// TODO (Phase 4): convert the cart's SlotHold into a confirmed Booking,
// consume capacity/resources, write the slot back onto the order via
// metafield, and release the hold. See IMPLEMENTATION_PLAN.md §5.2.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`, payload);
return new Response();
};

View File

@ -0,0 +1,12 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { authenticate } from "../shopify.server";
// TODO (Phase 4): sync Booking.status changes (e.g. fulfillment status)
// back from order updates. See IMPLEMENTATION_PLAN.md §5.2.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`, payload);
return new Response();
};

View File

@ -0,0 +1,16 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import db from "../db.server";
import { authenticate } from "../shopify.server";
// GDPR: shop uninstalled the app 48 hours ago — erase all shop data.
// TODO (Phase 8): once the full schema exists, delete every row scoped to
// this shopDomain across Location/SlotTemplate/Booking/etc.
export const action = async ({ request }: ActionFunctionArgs) => {
const { shop, topic, payload } = await authenticate.webhook(request);
console.log(`Received ${topic} webhook for ${shop}`, payload);
await db.session.deleteMany({ where: { shop } });
return new Response();
};

35
app/shopify.server.ts Normal file
View File

@ -0,0 +1,35 @@
import "@shopify/shopify-app-remix/adapters/node";
import {
ApiVersion,
AppDistribution,
shopifyApp,
} from "@shopify/shopify-app-remix/server";
import { PrismaSessionStorage } from "@shopify/shopify-app-session-storage-prisma";
import prisma from "./db.server";
const shopify = shopifyApp({
apiKey: process.env.SHOPIFY_API_KEY,
apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
apiVersion: ApiVersion.January25,
scopes: process.env.SCOPES?.split(","),
appUrl: process.env.SHOPIFY_APP_URL || "",
authPathPrefix: "/auth",
sessionStorage: new PrismaSessionStorage(prisma),
distribution: AppDistribution.AppStore,
future: {
unstable_newEmbeddedAuthStrategy: true,
expiringOfflineAccessTokens: true,
},
...(process.env.SHOP_CUSTOM_DOMAIN
? { customShopDomains: [process.env.SHOP_CUSTOM_DOMAIN] }
: {}),
});
export default shopify;
export const apiVersion = ApiVersion.January25;
export const addDocumentResponseHeaders = shopify.addDocumentResponseHeaders;
export const authenticate = shopify.authenticate;
export const unauthenticated = shopify.unauthenticated;
export const login = shopify.login;
export const registerWebhooks = shopify.registerWebhooks;
export const sessionStorage = shopify.sessionStorage;

2
env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="@remix-run/node" />

0
extensions/.gitkeep Normal file
View File

27
jobs/worker.ts Normal file
View File

@ -0,0 +1,27 @@
import { Worker } from "bullmq";
import { Redis } from "ioredis";
// BullMQ needs its own connection (can't share one used for pub/sub or with
// maxRetriesPerRequest set to a finite value in some modes) — kept separate
// from app/lib/redis.server.ts for that reason.
const connection = new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
// TODO (Phase 4): hold-expiry — release a SlotHold whose TTL has passed.
// TODO (Phase 9): notifications — send queued reminder/ETA messages.
// TODO (Phase 5+): capacity-recompute — recalculate denormalized capacity
// snapshots after config changes.
const holdExpiryWorker = new Worker(
"hold-expiry",
async (job) => {
console.log("Processing hold-expiry job", job.id, job.data);
},
{ connection },
);
holdExpiryWorker.on("failed", (job, err) => {
console.error(`hold-expiry job ${job?.id} failed:`, err);
});
console.log("Worker started, listening for jobs...");

18179
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

89
package.json Normal file
View File

@ -0,0 +1,89 @@
{
"name": "delivery-datetime-app",
"private": true,
"scripts": {
"build": "remix vite:build",
"dev": "shopify app dev",
"config:link": "shopify app config link",
"generate": "shopify app generate",
"deploy": "shopify app deploy",
"config:use": "shopify app config use",
"env": "shopify app env",
"start": "remix-serve ./build/server/index.js",
"docker-start": "npm run setup && npm run start",
"setup": "prisma generate && prisma migrate deploy",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:e2e": "playwright test",
"worker": "tsx jobs/worker.ts",
"shopify": "shopify",
"prisma": "prisma",
"graphql-codegen": "graphql-codegen",
"vite": "vite"
},
"type": "module",
"engines": {
"node": ">=20.19 <22 || >=22.12"
},
"dependencies": {
"@prisma/client": "^6.2.1",
"@remix-run/dev": "^2.16.1",
"@remix-run/fs-routes": "^2.16.1",
"@remix-run/node": "^2.16.1",
"@remix-run/react": "^2.16.1",
"@remix-run/serve": "^2.16.1",
"@shopify/app-bridge-react": "^4.1.6",
"@shopify/polaris": "^12.0.0",
"@shopify/shopify-app-remix": "^4.1.0",
"@shopify/shopify-app-session-storage-prisma": "^9.0.1",
"bullmq": "^5.34.0",
"ioredis": "^5.4.2",
"isbot": "^5.1.0",
"luxon": "^3.5.0",
"prisma": "^6.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"vite-tsconfig-paths": "^5.0.1"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@remix-run/eslint-config": "^2.16.1",
"@remix-run/route-config": "^2.16.1",
"@shopify/api-codegen-preset": "^1.1.1",
"@types/eslint": "^9.6.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.2.0",
"@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14",
"eslint": "^8.42.0",
"eslint-config-prettier": "^10.0.1",
"prettier": "^3.2.4",
"tsx": "^4.19.2",
"typescript": "^5.2.2",
"vite": "^6.2.2",
"vitest": "^2.1.8"
},
"workspaces": {
"packages": [
"extensions/*"
]
},
"trustedDependencies": [
"@shopify/plugin-cloudflare"
],
"resolutions": {
"@graphql-tools/url-loader": "8.0.16",
"@graphql-codegen/client-preset": "4.7.0",
"@graphql-codegen/typescript-operations": "4.5.0",
"minimatch": "9.0.5",
"vite": "^6.2.2"
},
"overrides": {
"@graphql-tools/url-loader": "8.0.16",
"@graphql-codegen/client-preset": "4.7.0",
"@graphql-codegen/typescript-operations": "4.5.0",
"minimatch": "9.0.5",
"vite": "^6.2.2"
}
}

11
playwright.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
reporter: "list",
use: {
baseURL: process.env.APP_URL || "http://localhost:3000",
trace: "on-first-retry",
},
});

View File

@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"shop" TEXT NOT NULL,
"state" TEXT NOT NULL,
"isOnline" BOOLEAN NOT NULL DEFAULT false,
"scope" TEXT,
"expires" DATETIME,
"accessToken" TEXT NOT NULL,
"userId" BIGINT,
"firstName" TEXT,
"lastName" TEXT,
"email" TEXT,
"accountOwner" BOOLEAN NOT NULL DEFAULT false,
"locale" TEXT,
"collaborator" BOOLEAN DEFAULT false,
"emailVerified" BOOLEAN DEFAULT false,
"refreshToken" TEXT,
"refreshTokenExpires" DATETIME
);

34
prisma/schema.prisma Normal file
View File

@ -0,0 +1,34 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
// Note that some adapters may set a maximum length for the String type by default, please ensure your strings are long
// enough when changing adapters.
// See https://www.prisma.io/docs/orm/reference/prisma-schema-reference#string for more information
datasource db {
provider = "sqlite"
url = "file:dev.sqlite"
}
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId BigInt?
firstName String?
lastName String?
email String?
accountOwner Boolean @default(false)
locale String?
collaborator Boolean? @default(false)
emailVerified Boolean? @default(false)
refreshToken String?
refreshTokenExpires DateTime?
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

73
shopify.app.toml Normal file
View File

@ -0,0 +1,73 @@
# This file stores configurations for your Shopify app.
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration
client_id = ""
name = "delivery-datetime-app"
application_url = "https://replace-with-your-tunnel-url.example.com"
embedded = true
[access_scopes]
# Minimum scopes for the v1 (Phase 0-4) feature set. Add more only when a
# feature in IMPLEMENTATION_PLAN.md actually needs it.
scopes = "read_products,read_customers,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales"
[auth]
redirect_urls = [
"https://replace-with-your-tunnel-url.example.com/auth/callback",
"https://replace-with-your-tunnel-url.example.com/auth/shopify/callback",
"https://replace-with-your-tunnel-url.example.com/api/auth/callback"
]
[webhooks]
api_version = "2024-10"
# Handled by: app/routes/webhooks.app.uninstalled.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/app/uninstalled"
topics = ["app/uninstalled"]
# Handled by: app/routes/webhooks.app.scopes_update.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/app/scopes_update"
topics = ["app/scopes_update"]
# Handled by: app/routes/webhooks.orders.create.tsx (Phase 4)
[[webhooks.subscriptions]]
uri = "/webhooks/orders/create"
topics = ["orders/create"]
# Handled by: app/routes/webhooks.orders.updated.tsx (Phase 4)
[[webhooks.subscriptions]]
uri = "/webhooks/orders/updated"
topics = ["orders/updated"]
# Handled by: app/routes/webhooks.orders.cancelled.tsx (Phase 4)
[[webhooks.subscriptions]]
uri = "/webhooks/orders/cancelled"
topics = ["orders/cancelled"]
# Mandatory GDPR compliance topics — required for Built-for-Shopify / public app review.
# Handled by: app/routes/webhooks.customers.data_request.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/customers/data_request"
compliance_topics = ["customers/data_request"]
# Handled by: app/routes/webhooks.customers.redact.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/customers/redact"
compliance_topics = ["customers/redact"]
# Handled by: app/routes/webhooks.shop.redact.tsx
[[webhooks.subscriptions]]
uri = "/webhooks/shop/redact"
compliance_topics = ["shop/redact"]
# App proxy so the storefront Theme App Extension can call our backend
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3).
[app_proxy]
url = "https://replace-with-your-tunnel-url.example.com/apps/scheduling"
subpath = "scheduling"
prefix = "apps"
[build]
include_config_on_deploy = true

11
shopify.web.toml.liquid Normal file
View File

@ -0,0 +1,11 @@
name = "remix"
roles = ["frontend", "backend"]
webhooks_path = "/webhooks/app/uninstalled"
{%- assign exec = dependency_manager | append: ' exec' -%}
{%- if dependency_manager == 'yarn' -%}
{%- assign exec = 'yarn' -%}
{%- endif %}
[commands]
predev = "{{ exec }} prisma generate"
dev = "{{ exec }} prisma migrate deploy && {{ exec }} remix vite:dev"

10
tests/unit/smoke.test.ts Normal file
View File

@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
// Phase 0 smoke test — proves the Vitest harness is wired into CI.
// Replace/extend with real coverage starting Phase 1 (template seeding)
// and Phase 2 (lib/time.ts, services/scheduling.server.ts).
describe("test harness", () => {
it("runs", () => {
expect(1 + 1).toBe(2);
});
});

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"include": ["env.d.ts", "**/*.ts", "**/*.tsx"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"removeComments": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"allowJs": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"baseUrl": ".",
"types": ["node"]
}
}

73
vite.config.ts Normal file
View File

@ -0,0 +1,73 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { installGlobals } from "@remix-run/node";
import { defineConfig, type UserConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
installGlobals({ nativeFetch: true });
// Related: https://github.com/remix-run/remix/issues/2835#issuecomment-1144102176
// Replace the HOST env var with SHOPIFY_APP_URL so that it doesn't break the remix server. The CLI will eventually
// stop passing in HOST, so we can remove this workaround after the next major release.
if (
process.env.HOST &&
(!process.env.SHOPIFY_APP_URL ||
process.env.SHOPIFY_APP_URL === process.env.HOST)
) {
process.env.SHOPIFY_APP_URL = process.env.HOST;
delete process.env.HOST;
}
const host = new URL(process.env.SHOPIFY_APP_URL || "http://localhost")
.hostname;
let hmrConfig;
if (host === "localhost") {
hmrConfig = {
protocol: "ws",
host: "localhost",
port: 64999,
clientPort: 64999,
};
} else {
hmrConfig = {
protocol: "wss",
host: host,
port: parseInt(process.env.FRONTEND_PORT!) || 8002,
clientPort: 443,
};
}
export default defineConfig({
server: {
allowedHosts: [host],
cors: {
preflightContinue: true,
},
port: Number(process.env.PORT || 3000),
hmr: hmrConfig,
fs: {
// See https://vitejs.dev/config/server-options.html#server-fs-allow for more information
allow: ["app", "node_modules"],
},
},
plugins: [
remix({
ignoredRouteFiles: ["**/.*"],
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_lazyRouteDiscovery: true,
v3_singleFetch: false,
v3_routeConfig: true,
},
}),
tsconfigPaths(),
],
build: {
assetsInlineLimit: 0,
},
optimizeDeps: {
include: ["@shopify/app-bridge-react", "@shopify/polaris"],
},
}) satisfies UserConfig;

10
vitest.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "node",
include: ["tests/unit/**/*.test.ts"],
},
});