Open-Source Cold Email & Warmup Platform
Warmbly는 메일함을 예열하고 캠페인 전송·답변 triage를 지원하는 오픈소스 콜드 이메일 플랫폼입니다
AI 기반 평판 관리 플랫폼으로 리뷰 수집·모니터링·분석·개선을 지원합니다
An AI-powered reputation management platform helping businesses collect, monitor, analyse and improve customer reviews. Automate review requests, track ratings and growth, and bring customers back, wi
마케팅GitHub @charliey36온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
저장소 정보는 아직 수집하지 않았습니다.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
Pentriq is an app currently in development. This is a tool to distribute emails and messages to customers after using a service, in order to request a google review for the business.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Pentriq is an AI-powered reputation management platform helping businesses collect, monitor, analyse and improve customer reviews, with retention and rebooking workflows built in.
Built with:
Key functionality includes:
Status: Active development.
A platform for service businesses to automate review requests, rebooking reminders, and customer retention (loyalty, referrals, win-back campaigns), built on top of a channel-agnostic messaging engine and a small journey scheduler.
This started as a minimal prototype and has grown into a fuller platform, but is still not a hardened production application. Several integration points (SMS/WhatsApp delivery, AI-drafted copy, real-time booking) are implemented as clean, swappable interfaces with a working fallback rather than live third-party integrations, since this environment has no real Twilio/LLM credentials to build and test against — see "Integration points that need real credentials" below for exactly what to wire up.
business_members table (no UI to invite
teammates yet, but the schema and RLS model support it).src/lib/compliance.ts for the enforced
invariant and why (FTC 16 CFR Part 465 / Google review-gating policy).
Feedback submitted privately shows up in the Feedback inbox page for
the business to acknowledge/resolve./book/[customerId]): captures a customer's
preferred time as a note for the business to confirm manually. This is
not a real-time booking engine with live availability — building one
requires either a native slot-scheduling system or an integration with
whatever calendar tool the business already uses, which the spec this
was built from explicitly calls out as a decision to validate with real
customers first rather than assume./segments): dynamic customer segments (e.g. "60+ days
since last visit AND lifetime value over $200"), evaluated live from
visit/spend/tag data rather than a static list that goes stale./referrals): generate a code for an existing customer,
redeem it against a new customer; both get rewarded with loyalty points
(if the program is active) once redeemed./analytics): 30-day rebooking rate, revenue
attributed to rebooking-reminder clicks (last-touch, 7-day window), and
a customer health-tier distribution (thriving/steady/at-risk/lapsed)
computed from a simple, explainable recency/frequency/monetary blend —
see src/lib/health.ts.ADMIN_EMAIL, your
dashboard also shows a table of every registered business.src/lib/ai.ts has a draftMessageCopy() function
that would call an LLM if OPENAI_API_KEY or ANTHROPIC_API_KEY is set,
and otherwise falls back to a deterministic template-based draft. No
real LLM call is implemented (no credentials to test against) — the
integration point is ready for a one-function addition once a provider
is chosen.These are built as clean, swappable interfaces with a working, honest fallback — not left out, but not fake either:
| Feature | File | Current behavior | To go live |
|---|---|---|---|
| SMS / WhatsApp sending | src/lib/messaging.ts | Logs the message server-side instead of delivering it (LoggingStubSmsProvider) | Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER, then implement the real Twilio REST call in createSmsProvider() — everything else (templates, retry, consent, resolveChannel) already works against the interface |
| AI-drafted message copy | src/lib/ai.ts | Falls back to a template-based draft | Set OPENAI_API_KEY or ANTHROPIC_API_KEY, then implement the completion call in draftMessageCopy() |
| One-click rebooking with live availability | src/app/book/[customerId] | Captures a preferred-time request as a note for manual follow-up | Either build a native slot-availability engine or integrate with the business's existing calendar/scheduling tool — see the comment in actions.ts |
supabase/schema.sql. This creates every table
(businesses, business_members, customers, customer_tags, services,
visits, messages, interaction_events, private_feedback, journeys,
journey_enrollments, segments, loyalty_programs, loyalty_ledger_entries,
referrals, import_jobs, plus the original review_requests/click_events)
along with Row Level Security policies, and backfills business_members
schema.sql is idempotent (create table if not exists, drop policy if exists + create policy) — safe to run again on a
database that already has some of these tables. Caveat: this
idempotency only applies to whole tables. If a table already exists,
create table if not exists does not add any new columns to it, even
ones listed in the statement — this caused a production incident
where customers.phone (among other columns) was never created
because customers pre-dated this schema version. Columns added to
an already-existing table must ship as a separate alter table ... add column if not exists migration (see 0003 below), not just be
added to this file's create table statement.supabase/migrations/0001_retry_and_consent.sql,
then schema.sql in full (see
supabase/migrations/0002_platform.sql
for why there's no separate incremental SQL file for this step), then
supabase/migrations/0003_fix_missing_columns.sql
(adds columns that step 2 silently skips on tables that pre-date this
schema version — safe/required even if you believe you already ran
the full schema), then
(creates the public Storage bucket and its RLS
policies, used by the profile picture uploader in Settings).[email protected] (Resend's
shared sandbox sender) without verifying a domain. For anything beyond a
demo, verify your own sending domain in Resend and set EMAIL_FROM
accordingly.Copy the example file and fill in your values:
cp .env.example .env.local
| Variable | Description |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | Supabase project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Supabase anon/public key |
SUPABASE_SERVICE_ROLE_KEY | Supabase service role key — server-only, never expose to the browser |
RESEND_API_KEY | Resend API key |
EMAIL_FROM | Verified sender address (or [email protected] for a demo) |
NEXT_PUBLIC_APP_URL | Public URL of this app, used to build tracking/unsubscribe/feedback/booking links (e.g. http://localhost:3000 locally) |
CRON_SECRET | Random secret required to call any /api/* scheduled-job endpoint |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Optional — OAuth client for the Google Business Profile integration (see section 9) |
GOOGLE_TOKEN_ENCRYPTION_KEY | Required with the above — 32-byte key (openssl rand -base64 32) used to encrypt stored Google tokens. Server-only; losing it means users must reconnect. |
GOOGLE_BUSINESS_API_APPROVED | Set to true once Google approves Business Profile API access; until then the Google card shows "Integration Coming Soon" |
GOOGLE_REDIRECT_URI | Optional — defaults to NEXT_PUBLIC_APP_URL + /api/google-business/callback |
ADMIN_EMAIL | If your logged-in email matches this, your dashboard also shows every registered business. Comma-separate multiple emails for more than one admin. |
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER | Optional — enables real SMS/WhatsApp sending once implemented in lib/messaging.ts (see Integration points above). Without these, SMS/WhatsApp sends are logged, not delivered. |
SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN | Optional — Sentry error monitoring (server and browser). Same DSN in both. Blank = off. |
| , , |
npm install
npm run dev
Visit http://localhost:3000, sign up, and you'll land on the dashboard.
ln -sf .env.local .env # one-time: lets docker compose read your env vars
docker compose up
Visit http://localhost:3000. The symlink is needed because Docker Compose
only reads a plain .env file (not .env.local) to fill in ${...}
placeholders used for build-time variables. Rebuild after changing
dependencies or NEXT_PUBLIC_* vars with docker compose up --build.
If you hit a TLS/certificate error inside the container (unable to get local issuer certificate) but everything works fine outside Docker, your
network likely runs a corporate TLS-inspecting proxy (e.g. Zscaler) whose
root CA is trusted on your host machine but unknown to a fresh container.
See the comments in Dockerfile.
npm run seed
Creates a demo business, its business_members row, and five sample
customers (mix of pending/sent review requests, one already clicked).
Demo login: [email protected] / demo-password-123 by default —
override with SEED_DEMO_EMAIL / SEED_DEMO_PASSWORD env vars.
Nothing sends automatically on a timer inside the Next.js app — external
schedulers must hit these endpoints on an interval. All are protected by
CRON_SECRET (as ?secret=... or Authorization: Bearer ...):
| Endpoint | What it does | Suggested interval |
|---|---|---|
GET /api/send-review-requests | Legacy single-send review request flow (the original review_requests table) | Every 5 min |
GET /api/send-messages | Processes the generalized messages table — review sequence follow-ups, rebooking reminders, win-back, birthday, referral, loyalty messages | Every 5 min |
GET /api/scan-customer-lifecycle | Daily scan: enrolls due customers into rebooking reminders, lapsed customers into win-back, birthday matches into birthday campaigns | Daily |
GET /api/sync-google-reviews | Syncs Google reviews for every connected business, stores the daily snapshot and raises alerts (section 9) | Daily on Vercel Hobby (every 6 hours on Pro or an external scheduler) |
Locally:
curl "http://localhost:3000/api/send-messages?secret=YOUR_CRON_SECRET"
In production, use Vercel Cron (set CRON_SECRET in Vercel and it
sends Authorization: Bearer <CRON_SECRET> automatically; no secret goes in vercel.json) or any external
scheduler (e.g. cron-job.org, a GitHub Actions
scheduled workflow) hitting the same URLs.
{NEXT_PUBLIC_APP_URL}/r/{tracking_token} (never straight to Google). The
endpoint (src/app/r/[token]/route.ts,
logic in src/lib/click-tracking.ts):
messages, or legacy review_requests) by its
unguessable tracking_token;review_link_scan and not counted, and never cancel
follow-up reminders;review_link_clicked row in interaction_events (message /
review request id, customer id, campaign (journey) id, timestamp, user
agent, anonymised IP — last IPv4 octet dropped / IPv6 cut to /48 —
and the destination URL) and updates click_count, first_clicked_at,
last_clicked_at, destination_url on the send;302s to the business's review URL (or the branded
/review-unavailable page if none is set). The redirect always happens,
even if recording fails./api/track/[reviewRequestId]
and /api/track-message/[messageId] are still served and share the same
handler. If migration 0018 hasn't been applied yet, links fall back to the
row id and clicks fall back to the older table shapes, so nothing breaks —
but run the migration to get per-send counters.If you log in with the email set as ADMIN_EMAIL, your dashboard also
shows a table of every business that has signed up. Comma-separate
multiple addresses to grant more than one account admin access.
Users connect their Google account with OAuth (no review URL is typed anywhere), pick a location, and Pentriq imports and tracks the location's reviews.
User flow: Settings → Integrations → Connect Google Business → Google consent → choose a location → Save location & import reviews (the initial sync runs immediately) → Reviews Analytics in the sidebar.
{NEXT_PUBLIC_APP_URL}/api/google-business/callback as an authorised
redirect URI (and http://localhost:3000/api/google-business/callback for
local dev). Configure the OAuth consent screen with the scope
https://www.googleapis.com/auth/business.manage.GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and
GOOGLE_TOKEN_ENCRYPTION_KEY (see the env table) and apply migrations
0019_google_business_profile.sql and
0020_google_granted_scopes.sql.
Local development: the OAuth callback must return to the origin that started the flow. If
NEXT_PUBLIC_APP_URL in .env.local is your production URL, also set
GOOGLE_REDIRECT_URI=http://localhost:3000/api/google-business/callback and register that exact URI
in Google Cloud Console (the connect route refuses to start in development when they differ).CRON_SECRET is set. vercel.json schedules
/api/sync-google-reviews once a day (0 6 * * *), because Vercel's Hobby plan
only allows daily crons and rejects the deployment otherwise. For the intended
6-hour freshness, either upgrade to Pro and change the schedule to 0 */6 * * *, or
leave Vercel's daily run and add an external scheduler (e.g. cron-job.org) that calls
GET /api/sync-google-reviews every 6 hours with the header Authorization: Bearer <CRON_SECRET>.
Users can also press at any time.Troubleshooting. Run node --env-file=.env.local scripts/check-google-oauth.mjs: it validates the env
vars, the client ID/secret, which redirect URIs Google has registered, and the database tables, without
needing a login. OAuth failures are logged as [google-oauth] {json} lines (no tokens, codes or secrets) in
your terminal / Vercel logs; the event field says where it failed (state_check_failed,
token_exchange_failed, scope_missing, accounts_list_failed, token_refresh_failed, api_call_failed).
| Piece | Where |
|---|---|
OAuth start / callback (CSRF state in an httpOnly cookie) | src/app/api/google-business/ |
| Location picker, sync now, disconnect, alert threshold | src/app/(app)/settings/integrations/ |
| Google API client (accounts, locations, reviews, replies) | src/lib/google-business.ts |
| Token encryption (AES-256-GCM) | src/lib/google-token-crypto.ts |
| Sync engine (fetch → insert/update → stats → snapshot → alerts) | src/lib/google-review-sync.ts |
| Pure analytics (cards, series, insights, filters) | src/lib/google-review-analytics.ts |
| Alert rules | src/lib/google-review-alerts.ts |
| Scheduled job | src/app/api/sync-google-reviews/route.ts |
| Reviews Analytics page (Recharts) | src/app/(app)/reviews/ |
Tables (migration 0019): google_business_connections (one per business;
OAuth tokens encrypted; RLS on with no policies, so only the server's
service role can read it), google_reviews, review_metrics_daily (one
snapshot per day: total, average, gained) and review_alerts. Reviews,
metrics and alerts are readable by business members through RLS; all writes
happen server-side.
Sync behaviour. The first sync pages through every review, prunes any that
no longer exist on Google, and backfills daily snapshots from review dates so
charts have history immediately. Later syncs (scheduled, or Sync now)
fetch reviews ordered by updateTime and stop once a page predates the last
sync (with a day of overlap), inserting new reviews and updating edited ones
and replies. Today's snapshot uses Google's own averageRating /
totalReviewCount. Dates are bucketed in Europe/London.
Replies. Replying posts to Google first and only then stores the text, so the database never shows a reply that isn't live. Later syncs pull replies back from Google, so edits made on Google also appear. Replies are limited to 4,096 characters.
Alerts (in-app, shown on the Reviews Analytics page with an unread badge in the sidebar; 1-star, low-rating and month-over-month alerts are also emailed to the owner):
The first (historical) import never raises alerts. Alerts are de-duplicated by
(business_id, dedupe_key), so re-running a sync is safe.
Rating Change = current overall average − overall average at the end of the previous calendar month. The date filter (7/30/90 days, 12 months, custom) scopes the rating-trend chart, rating distribution and review list; the summary cards and monthly insights always describe the current month, and the growth chart has its own period switcher (this month / 3 months / 12 months).
Disconnecting revokes the token at Google and deletes the connection and all data synced from it (reviews, snapshots, alerts). Changing location replaces the previous location's data.
Limitations. Requires approved Business Profile API access (see setup). Reviews deleted on Google are only removed locally by a full re-import (disconnect and reconnect, or change location). Review replies can be created and edited from Pentriq but not deleted.
Google's Business Profile API needs Google's approval, so reputation tracking also works manually: Settings → Reputation Tracking lets an owner record the current review count and average rating on any date. Snapshots can be edited, deleted and the analytics rebuilt from the same page.
0021_reputation_tracking.sql):
manual_review_snapshots, review_sources, review_metrics (monthly roll-up rebuilt on every change) and,
for Google, google_business_locations and google_sync_jobs (one row per sync run). The existing
review_alerts table serves every source (a manual rating drop raises a rating_decrease alert).reputation-data.ts loads either Google's
review_metrics_daily (once a synced location has data) or the manual snapshots as the same daily series, and
reputation-metrics.ts (pure, tested) computes gains, rating change,
review velocity (daily pace vs last month), monthly series and the plain-English insights./reviews) has Analytics, Reviews, Insights, Alerts and Connections tabs. Individual
reviews, the star distribution and replies appear only for Google data.GOOGLE_BUSINESS_API_APPROVED=true. Until then the Google card in
Settings → Integrations shows "Integration Coming Soon" (an existing connection still shows its controls).
Nothing else changes: after the first sync the dashboard switches to the live data automatically..env.example in Project Settings → Environment
Variables.NEXT_PUBLIC_APP_URL to your Vercel deployment URL.CRON_SECRET is set so Vercel Cron can authenticate against the
scheduled-job endpoints.src/
app/
(app)/ Authenticated shell: layout, nav, logout
dashboard/ Funnel + conversion rate, pending/failed counts
customers/ List, add, CSV import, [customerId] detail page
segments/ Dynamic segment builder
referrals/ Generate + redeem referral codes
feedback-inbox/ Private feedback inbox (acknowledge/resolve)
analytics/ Rebooking rate, revenue attribution, health tiers
reviews/ Reviews Analytics: tabs, cards, Recharts charts, insights, alerts (manual or Google data)
settings/ Business settings, services catalog, loyalty program
api/
send-review-requests/ Legacy single-send scheduled job
send-messages/ Generalized multi-purpose/multi-channel scheduled job
scan-customer-lifecycle/ Daily rebooking/win-back/birthday enrollment scan
google-business/ OAuth connect + callback for Google Business Profile
sync-google-reviews/ Scheduled Google review sync (daily; see section 9)
track/[id]/ Legacy click tracking + redirect
track-message/[id]/ Generalized click tracking + redirect + journey exit
unsubscribe/[customerId]/ Public unsubscribe endpoint
book/[customerId]/ Public rebooking request landing page
r/[token]/ Public tracked review link: records click, redirects to Google
feedback/[messageId]/ Public private feedback landing page
auth/callback/, login/, signup/
components/ Shared UI (auth form, nav links, logo)
lib/
supabase/ Browser / server / admin Supabase clients
business.ts Loads (or lazily creates) the logged-in user's business + membership
messaging.ts Channel-agnostic send abstraction (email real, SMS/WhatsApp stubbed)
templates.ts Message copy per purpose/channel, compliance-enforced for review purposes
compliance.ts Enforces the "always offer both public + private" invariant
journeys.ts Minimal journey engine (enroll/advance/exit)
visits.ts Rebooking status + lifetime value calculations
segments.ts Live segment rule evaluator
health.ts Rebooking rate, revenue attribution, health scoring
customers.ts CSV parsing + bulk import
ai.ts AI drafting hook (template fallback, provider-pluggable)
database.types.ts Hand-written types matching supabase/schema.sql
supabase/
schema.sql Full table set + RLS policies (idempotent)
migrations/ Incremental SQL for upgrading existing databases
scripts/seed.mjs Demo data seed script
Dockerfile, docker-compose.yml, docker/certs/ Optional Docker setup
src/app/book/[customerId]/actions.ts.src/lib/segments.ts).npm test); there are no end-to-end browser tests.supabase/migrations/0004_business_logo_storage.sqlbusiness-logossupabase/migrations/ in numeric order. Recent ones to be
aware of: 0023 makes the free trial opt-in (existing accounts are
grandfathered), 0024 adds the rate limiter used by the public
unsubscribe/feedback/booking endpoints, 0025 records customer
consent, and 0026 stops signed-in users editing their own billing
fields or deleting their business from the browser. Deploy the code only after applying them. In particular,
0017_ensure_business_settings_columns.sql
guarantees the columns the Settings page saves
(review_request_window_days, rebooking_reminder_interval_days,
rebooking_reminders_enabled) exist and reloads the PostgREST schema
cache (notify pgrst, 'reload schema'). If Settings fails to save with
"Could not find the '…' column of 'businesses' in the schema cache",
run it in the Supabase SQL editor; the app also logs this at startup
(DATABASE SCHEMA VALIDATION FAILED).anon public keyservice_role secret keySENTRY_AUTH_TOKENSENTRY_ORGSENTRY_PROJECT| Optional, build-time — upload source maps and create a release per deploy (release = Git commit). |
OPENAI_API_KEY or ANTHROPIC_API_KEY | Optional — enables real AI-drafted message copy once implemented in lib/ai.ts. Without these, drafting falls back to templates. |
review_confirmed/feedback/[messageId] is the public private-feedback landing page
linked from every review request./api/unsubscribe/[customerId] stops all future emails to a customer.business.manage is a
sensitive scope, so public users will see an "unverified app" warning until Google verifies the app.