Skip to content

Commit c37fcd7

Browse files
authored
Merge pull request #2 from Parra-Inc/claude/admin-dashboard-setup-h9mlX
Add comprehensive admin dashboard with users, quotes, workspaces, and support management
2 parents a80d4fb + 83ed555 commit c37fcd7

File tree

55 files changed

+5282
-55
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+5282
-55
lines changed

.env.example

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ SLACK_SIGNING_SECRET=
55
SLACK_APP_ID=
66

77
# NextAuth (auto-generated by setup script)
8-
NEXTAUTH_URL=http://localhost:3000
8+
NEXTAUTH_URL=http://localhost:3001
99
NEXTAUTH_SECRET=
1010

1111
# Database (matches docker-compose.yml defaults)
@@ -63,5 +63,15 @@ BLOB_READ_WRITE_TOKEN=
6363
# Encryption (auto-generated by setup script)
6464
TOKEN_ENCRYPTION_KEY=
6565

66+
# X (Twitter) — OAuth 1.0a credentials for posting
67+
X_CONSUMER_KEY=
68+
X_CONSUMER_SECRET=
69+
X_ACCESS_TOKEN=
70+
X_ACCESS_SECRET=
71+
72+
# Instagram Graph API
73+
INSTAGRAM_ACCESS_TOKEN=
74+
INSTAGRAM_BUSINESS_ACCOUNT_ID=
75+
6676
# App
67-
NEXT_PUBLIC_APP_URL=http://localhost:3000
77+
NEXT_PUBLIC_APP_URL=http://localhost:3001

dev/TESTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Both databases have been seeded with test data to help you test the integration
5555

5656
## URLs
5757

58-
- **Main App**: http://localhost:3000
58+
- **Main App**: http://localhost:3001
5959
- **Slackhog GUI**: http://localhost:9002
6060
- **MailHog**: http://localhost:8026
6161
- **Database (PostgreSQL)**: `localhost:5433`

jest.setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ process.env.SLACK_SIGNING_SECRET = "test-signing-secret";
1212
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
1313
process.env.SLACK_CLIENT_ID = "test-client-id";
1414
process.env.SLACK_CLIENT_SECRET = "test-client-secret";
15-
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
15+
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3001";

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "",
55
"packageManager": "pnpm@10.12.4",
66
"scripts": {
7-
"dev": "concurrently --kill-others \"next dev --turbopack\" \"pnpm run db:sync --accept-data-loss && pnpm run db:studio\"",
7+
"dev": "concurrently --kill-others \"next dev --turbopack --port 3001\" \"pnpm run db:sync --accept-data-loss && pnpm run db:studio\"",
88
"build": "prisma generate && next build",
99
"start": "next start",
1010
"lint": "eslint . && prettier --check .",
@@ -68,6 +68,7 @@
6868
"stripe": "^20.3.1",
6969
"tailwind-merge": "^3.5.0",
7070
"tailwindcss": "^4.2.0",
71+
"twitter-api-v2": "^1.29.0",
7172
"typescript": "^5.9.3",
7273
"zod": "^4.3.6"
7374
},

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

prisma/schema.prisma

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ model Quote {
132132
133133
imageGenerations ImageGeneration[]
134134
collectionQuotes CollectionQuote[]
135+
socialPost SocialPost?
135136
136137
@@unique([workspaceId, slackMessageTs])
137138
@@index([workspaceId, createdAt])
@@ -304,6 +305,55 @@ model EmailVerificationCode {
304305
@@index([expires])
305306
}
306307

308+
enum SocialPostStatus {
309+
DRAFT
310+
SCHEDULED
311+
PUBLISHED
312+
PARTIALLY_PUBLISHED
313+
FAILED
314+
}
315+
316+
enum SocialChannelStatus {
317+
DRAFT
318+
PUBLISHED
319+
FAILED
320+
}
321+
322+
model SocialPost {
323+
id String @id @default(cuid())
324+
quoteId String @unique
325+
quote Quote @relation(fields: [quoteId], references: [id], onDelete: Cascade)
326+
content String
327+
status SocialPostStatus @default(DRAFT)
328+
scheduledAt DateTime?
329+
publishedAt DateTime?
330+
qstashMessageId String?
331+
createdAt DateTime @default(now())
332+
updatedAt DateTime @updatedAt
333+
334+
channels SocialPostChannel[]
335+
336+
@@index([status, scheduledAt])
337+
@@index([createdAt])
338+
}
339+
340+
model SocialPostChannel {
341+
id String @id @default(cuid())
342+
socialPostId String
343+
socialPost SocialPost @relation(fields: [socialPostId], references: [id], onDelete: Cascade)
344+
channel String
345+
content String?
346+
status SocialChannelStatus @default(DRAFT)
347+
publishedAt DateTime?
348+
platformId String?
349+
error String?
350+
createdAt DateTime @default(now())
351+
updatedAt DateTime @updatedAt
352+
353+
@@unique([socialPostId, channel])
354+
@@index([socialPostId])
355+
}
356+
307357
model Collection {
308358
id String @id @default(cuid())
309359
name String

specs/slack-developer-setup.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Navigate to **OAuth & Permissions**:
4343

4444
Add your OAuth callback URL:
4545

46-
- **Dev**: `http://localhost:3000/api/auth/callback/slack`
46+
- **Dev**: `http://localhost:3001/api/auth/callback/slack`
4747
- **Prod**: `https://nocontextbot.com/api/auth/callback/slack`
4848

4949
> You may need separate redirect URLs for the Slack install flow vs. NextAuth sign-in. If so:
@@ -143,10 +143,10 @@ For local development, you need a public URL that tunnels to your local server:
143143
brew install ngrok
144144

145145
# Start your Next.js dev server
146-
npm run dev # runs on localhost:3000
146+
npm run dev # runs on localhost:3001
147147

148148
# In another terminal, start ngrok
149-
ngrok http 3000
149+
ngrok http 3001
150150
```
151151

152152
ngrok gives you a URL like `https://abc123.ngrok.io`. Update these in your Slack app settings:
@@ -171,7 +171,7 @@ SLACK_SIGNING_SECRET=your_signing_secret
171171
SLACK_APP_ID=your_app_id
172172
173173
# NextAuth
174-
NEXTAUTH_URL=http://localhost:3000
174+
NEXTAUTH_URL=http://localhost:3001
175175
NEXTAUTH_SECRET=generate-a-random-secret
176176
177177
# Database
@@ -231,7 +231,7 @@ When ready to distribute publicly:
231231
| Item | Dev | Production |
232232
| -------------- | -------------------------- | ------------------------------------ |
233233
| Slack App | Separate "Dev" app | Production app |
234-
| OAuth redirect | `localhost:3000` via ngrok | `nocontextbot.com` |
234+
| OAuth redirect | `localhost:3001` via ngrok | `nocontextbot.com` |
235235
| Event URL | ngrok tunnel | Production server |
236236
| Bot token | Single workspace token | Per-workspace via OAuth |
237237
| Stripe keys | `sk_test_` / `pk_test_` | `sk_live_` / `pk_live_` |

specs/stripe-setup.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ STRIPE_PRICE_BUSINESS_ANNUAL=price_1T0TUbLZJUxFcJI8SXetTjkd
5858
Use the Stripe CLI to forward webhook events:
5959

6060
```bash
61-
stripe listen --forward-to localhost:3000/api/stripe/webhook
61+
stripe listen --forward-to localhost:3001/api/stripe/webhook
6262
```
6363

6464
Copy the webhook signing secret (`whsec_...`) and set it as `STRIPE_WEBHOOK_SECRET`.

specs/upstash-setup.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ QStash needs a publicly accessible URL to deliver messages. For local developmen
102102
### Option A: Use a tunnel (ngrok, Cloudflare Tunnel, etc.)
103103

104104
1. Start your dev server: `npm run dev`
105-
2. Start a tunnel: `ngrok http 3000`
105+
2. Start a tunnel: `ngrok http 3001`
106106
3. Set `NEXT_PUBLIC_APP_URL` to the tunnel URL in `.env`
107107
4. QStash will deliver jobs to your local machine through the tunnel
108108

src/app/(admin)/admin/page.tsx

Lines changed: 143 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,145 @@
1-
import { redirect } from "next/navigation";
1+
import prisma from "@/lib/prisma";
2+
import { AdminDashboard } from "@/components/admin/admin-dashboard";
23

3-
export default function AdminPage() {
4-
redirect("/admin/styles");
4+
export default async function AdminPage() {
5+
const now = new Date();
6+
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
7+
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
8+
9+
const [
10+
totalUsers,
11+
newUsers30d,
12+
totalWorkspaces,
13+
activeWorkspaces,
14+
totalQuotes,
15+
quotes30d,
16+
completedQuotes,
17+
failedQuotes,
18+
totalImages,
19+
images30d,
20+
subscriptionsByTier,
21+
totalChannels,
22+
activeChannels,
23+
newContactSubmissions,
24+
recentQuotes,
25+
recentUsers,
26+
tokenPurchaseRevenue,
27+
weeklyActiveWorkspaceIds,
28+
] = await Promise.all([
29+
prisma.user.count(),
30+
prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
31+
prisma.workspace.count(),
32+
prisma.workspace.count({ where: { isActive: true } }),
33+
prisma.quote.count(),
34+
prisma.quote.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
35+
prisma.quote.count({ where: { status: "COMPLETED" } }),
36+
prisma.quote.count({ where: { status: "FAILED" } }),
37+
prisma.imageGeneration.count({ where: { status: "COMPLETED" } }),
38+
prisma.imageGeneration.count({
39+
where: { status: "COMPLETED", createdAt: { gte: thirtyDaysAgo } },
40+
}),
41+
prisma.subscription.groupBy({
42+
by: ["tier"],
43+
_count: { id: true },
44+
}),
45+
prisma.channel.count(),
46+
prisma.channel.count({ where: { isActive: true } }),
47+
prisma.contactFormSubmission.count({ where: { status: "new" } }),
48+
prisma.quote.findMany({
49+
where: { status: "COMPLETED" },
50+
orderBy: { createdAt: "desc" },
51+
take: 5,
52+
select: {
53+
id: true,
54+
quoteText: true,
55+
imageUrl: true,
56+
slackUserName: true,
57+
createdAt: true,
58+
workspace: { select: { slackTeamName: true } },
59+
},
60+
}),
61+
prisma.user.findMany({
62+
orderBy: { createdAt: "desc" },
63+
take: 5,
64+
select: {
65+
id: true,
66+
name: true,
67+
email: true,
68+
createdAt: true,
69+
},
70+
}),
71+
prisma.tokenPurchase.aggregate({
72+
_sum: { amountPaid: true },
73+
}),
74+
prisma.quote.findMany({
75+
where: { createdAt: { gte: sevenDaysAgo } },
76+
select: { workspaceId: true },
77+
distinct: ["workspaceId"],
78+
}),
79+
]);
80+
81+
const tierBreakdown: Record<string, number> = {};
82+
for (const tier of subscriptionsByTier) {
83+
tierBreakdown[tier.tier] = tier._count.id;
84+
}
85+
86+
const paidSubscriptions =
87+
(tierBreakdown["STARTER"] || 0) +
88+
(tierBreakdown["TEAM"] || 0) +
89+
(tierBreakdown["BUSINESS"] || 0) +
90+
(tierBreakdown["ENTERPRISE"] || 0);
91+
92+
const totalSubscriptions = Object.values(tierBreakdown).reduce(
93+
(a, b) => a + b,
94+
0,
95+
);
96+
97+
const conversionRate =
98+
totalSubscriptions > 0
99+
? parseFloat(((paidSubscriptions / totalSubscriptions) * 100).toFixed(1))
100+
: 0;
101+
102+
const quoteSuccessRate =
103+
completedQuotes + failedQuotes > 0
104+
? parseFloat(
105+
((completedQuotes / (completedQuotes + failedQuotes)) * 100).toFixed(
106+
1,
107+
),
108+
)
109+
: 100;
110+
111+
return (
112+
<AdminDashboard
113+
stats={{
114+
totalUsers,
115+
newUsers30d,
116+
totalWorkspaces,
117+
activeWorkspaces,
118+
weeklyActiveWorkspaces: weeklyActiveWorkspaceIds.length,
119+
totalQuotes,
120+
quotes30d,
121+
totalImages,
122+
images30d,
123+
totalChannels,
124+
activeChannels,
125+
newContactSubmissions,
126+
paidSubscriptions,
127+
freeSubscriptions: tierBreakdown["FREE"] || 0,
128+
conversionRate,
129+
quoteSuccessRate,
130+
completedQuotes,
131+
failedQuotes,
132+
totalTokenRevenue: (tokenPurchaseRevenue._sum.amountPaid || 0) / 100,
133+
tierBreakdown,
134+
}}
135+
recentQuotes={recentQuotes.map((q) => ({
136+
...q,
137+
createdAt: q.createdAt.toISOString(),
138+
}))}
139+
recentUsers={recentUsers.map((u) => ({
140+
...u,
141+
createdAt: u.createdAt.toISOString(),
142+
}))}
143+
/>
144+
);
5145
}

0 commit comments

Comments
 (0)