karaoke-gen 0.99.3__py3-none-any.whl → 0.101.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/api/routes/admin.py +512 -1
- backend/api/routes/audio_search.py +13 -2
- backend/api/routes/file_upload.py +42 -1
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +9 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +167 -245
- backend/main.py +6 -1
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/firestore_service.py +6 -0
- backend/services/job_manager.py +32 -1
- backend/services/stripe_service.py +61 -35
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +146 -1
- backend/tests/test_made_for_you.py +2086 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/workers/video_worker.py +8 -3
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +42 -28
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -14,7 +14,9 @@ import html
|
|
|
14
14
|
import logging
|
|
15
15
|
import os
|
|
16
16
|
from abc import ABC, abstractmethod
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
17
18
|
from typing import Optional, List
|
|
19
|
+
from zoneinfo import ZoneInfo
|
|
18
20
|
|
|
19
21
|
from backend.config import get_settings
|
|
20
22
|
from karaoke_gen.utils import sanitize_filename
|
|
@@ -34,8 +36,21 @@ class EmailProvider(ABC):
|
|
|
34
36
|
html_content: str,
|
|
35
37
|
text_content: Optional[str] = None,
|
|
36
38
|
cc_emails: Optional[List[str]] = None,
|
|
39
|
+
bcc_emails: Optional[List[str]] = None,
|
|
40
|
+
from_email_override: Optional[str] = None,
|
|
37
41
|
) -> bool:
|
|
38
|
-
"""
|
|
42
|
+
"""
|
|
43
|
+
Send an email. Returns True if successful.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
to_email: Recipient email address
|
|
47
|
+
subject: Email subject
|
|
48
|
+
html_content: HTML content
|
|
49
|
+
text_content: Plain text content (optional)
|
|
50
|
+
cc_emails: CC recipients (optional)
|
|
51
|
+
bcc_emails: BCC recipients (optional)
|
|
52
|
+
from_email_override: Override the default sender email (optional, for multi-tenant)
|
|
53
|
+
"""
|
|
39
54
|
pass
|
|
40
55
|
|
|
41
56
|
|
|
@@ -53,11 +68,17 @@ class ConsoleEmailProvider(EmailProvider):
|
|
|
53
68
|
html_content: str,
|
|
54
69
|
text_content: Optional[str] = None,
|
|
55
70
|
cc_emails: Optional[List[str]] = None,
|
|
71
|
+
bcc_emails: Optional[List[str]] = None,
|
|
72
|
+
from_email_override: Optional[str] = None,
|
|
56
73
|
) -> bool:
|
|
57
74
|
logger.info("=" * 60)
|
|
75
|
+
if from_email_override:
|
|
76
|
+
logger.info(f"FROM: {from_email_override}")
|
|
58
77
|
logger.info(f"EMAIL TO: {to_email}")
|
|
59
78
|
if cc_emails:
|
|
60
79
|
logger.info(f"CC: {', '.join(cc_emails)}")
|
|
80
|
+
if bcc_emails:
|
|
81
|
+
logger.info(f"BCC: {', '.join(bcc_emails)}")
|
|
61
82
|
logger.info(f"SUBJECT: {subject}")
|
|
62
83
|
logger.info("-" * 60)
|
|
63
84
|
logger.info(text_content or html_content)
|
|
@@ -85,6 +106,8 @@ class PreviewEmailProvider(EmailProvider):
|
|
|
85
106
|
html_content: str,
|
|
86
107
|
text_content: Optional[str] = None,
|
|
87
108
|
cc_emails: Optional[List[str]] = None,
|
|
109
|
+
bcc_emails: Optional[List[str]] = None,
|
|
110
|
+
from_email_override: Optional[str] = None,
|
|
88
111
|
) -> bool:
|
|
89
112
|
self.last_to_email = to_email
|
|
90
113
|
self.last_subject = subject
|
|
@@ -115,16 +138,21 @@ class SendGridEmailProvider(EmailProvider):
|
|
|
115
138
|
html_content: str,
|
|
116
139
|
text_content: Optional[str] = None,
|
|
117
140
|
cc_emails: Optional[List[str]] = None,
|
|
141
|
+
bcc_emails: Optional[List[str]] = None,
|
|
142
|
+
from_email_override: Optional[str] = None,
|
|
118
143
|
) -> bool:
|
|
119
144
|
try:
|
|
120
145
|
# Import here to avoid requiring sendgrid in all environments
|
|
121
146
|
from sendgrid import SendGridAPIClient
|
|
122
|
-
from sendgrid.helpers.mail import Mail, Email, To, Content, Cc
|
|
147
|
+
from sendgrid.helpers.mail import Mail, Email, To, Content, Cc, Bcc
|
|
123
148
|
|
|
124
149
|
sg = SendGridAPIClient(api_key=self.api_key)
|
|
125
150
|
|
|
151
|
+
# Use override sender if provided (for multi-tenant)
|
|
152
|
+
sender_email = from_email_override or self.from_email
|
|
153
|
+
|
|
126
154
|
message = Mail(
|
|
127
|
-
from_email=Email(
|
|
155
|
+
from_email=Email(sender_email, self.from_name),
|
|
128
156
|
to_emails=To(to_email),
|
|
129
157
|
subject=subject,
|
|
130
158
|
html_content=Content("text/html", html_content)
|
|
@@ -144,6 +172,19 @@ class SendGridEmailProvider(EmailProvider):
|
|
|
144
172
|
for cc_email in unique_cc_emails:
|
|
145
173
|
message.add_cc(Cc(cc_email))
|
|
146
174
|
|
|
175
|
+
# Add BCC recipients if provided (deduplicated and normalized)
|
|
176
|
+
if bcc_emails:
|
|
177
|
+
seen = set()
|
|
178
|
+
unique_bcc_emails = []
|
|
179
|
+
for bcc_email in bcc_emails:
|
|
180
|
+
normalized = bcc_email.strip().lower()
|
|
181
|
+
if normalized and normalized not in seen:
|
|
182
|
+
seen.add(normalized)
|
|
183
|
+
unique_bcc_emails.append(bcc_email.strip())
|
|
184
|
+
|
|
185
|
+
for bcc_email in unique_bcc_emails:
|
|
186
|
+
message.add_bcc(Bcc(bcc_email))
|
|
187
|
+
|
|
147
188
|
if text_content:
|
|
148
189
|
message.add_content(Content("text/plain", text_content))
|
|
149
190
|
|
|
@@ -151,7 +192,8 @@ class SendGridEmailProvider(EmailProvider):
|
|
|
151
192
|
|
|
152
193
|
if response.status_code >= 200 and response.status_code < 300:
|
|
153
194
|
cc_info = f" (CC: {', '.join(cc_emails)})" if cc_emails else ""
|
|
154
|
-
|
|
195
|
+
bcc_info = f" (BCC: {', '.join(bcc_emails)})" if bcc_emails else ""
|
|
196
|
+
logger.info(f"Email sent to {to_email}{cc_info}{bcc_info} via SendGrid")
|
|
155
197
|
return True
|
|
156
198
|
else:
|
|
157
199
|
logger.error(f"SendGrid returned status {response.status_code}")
|
|
@@ -200,13 +242,19 @@ class EmailService:
|
|
|
200
242
|
"""Check if a real email provider is configured (not just console logging)."""
|
|
201
243
|
return isinstance(self.provider, SendGridEmailProvider)
|
|
202
244
|
|
|
203
|
-
def send_magic_link(
|
|
245
|
+
def send_magic_link(
|
|
246
|
+
self,
|
|
247
|
+
email: str,
|
|
248
|
+
token: str,
|
|
249
|
+
sender_email: Optional[str] = None,
|
|
250
|
+
) -> bool:
|
|
204
251
|
"""
|
|
205
252
|
Send a magic link email for authentication.
|
|
206
253
|
|
|
207
254
|
Args:
|
|
208
255
|
email: User's email address
|
|
209
256
|
token: Magic link token
|
|
257
|
+
sender_email: Override sender email address (for multi-tenant)
|
|
210
258
|
|
|
211
259
|
Returns:
|
|
212
260
|
True if email was sent successfully
|
|
@@ -264,7 +312,9 @@ If you didn't request this email, you can safely ignore it.
|
|
|
264
312
|
© {self._get_year()} Nomad Karaoke
|
|
265
313
|
"""
|
|
266
314
|
|
|
267
|
-
return self.provider.send_email(
|
|
315
|
+
return self.provider.send_email(
|
|
316
|
+
email, subject, html_content, text_content, from_email_override=sender_email
|
|
317
|
+
)
|
|
268
318
|
|
|
269
319
|
def send_credits_added(self, email: str, credits: int, total_credits: int) -> bool:
|
|
270
320
|
"""
|
|
@@ -1017,6 +1067,7 @@ Thanks for being part of making Nomad Karaoke better!
|
|
|
1017
1067
|
html_content=html_content,
|
|
1018
1068
|
text_content=message_content,
|
|
1019
1069
|
cc_emails=cc_emails,
|
|
1070
|
+
bcc_emails=["done@nomadkaraoke.com"],
|
|
1020
1071
|
)
|
|
1021
1072
|
|
|
1022
1073
|
def send_action_reminder(
|
|
@@ -1080,6 +1131,202 @@ Thanks for being part of making Nomad Karaoke better!
|
|
|
1080
1131
|
text_content=message_content,
|
|
1081
1132
|
)
|
|
1082
1133
|
|
|
1134
|
+
def send_made_for_you_order_confirmation(
|
|
1135
|
+
self,
|
|
1136
|
+
to_email: str,
|
|
1137
|
+
artist: str,
|
|
1138
|
+
title: str,
|
|
1139
|
+
job_id: str,
|
|
1140
|
+
notes: Optional[str] = None,
|
|
1141
|
+
) -> bool:
|
|
1142
|
+
"""
|
|
1143
|
+
Send order confirmation email to customer for made-for-you orders.
|
|
1144
|
+
|
|
1145
|
+
Args:
|
|
1146
|
+
to_email: Customer's email address
|
|
1147
|
+
artist: Artist name
|
|
1148
|
+
title: Song title
|
|
1149
|
+
job_id: Order/job ID for reference
|
|
1150
|
+
notes: Optional customer notes
|
|
1151
|
+
|
|
1152
|
+
Returns:
|
|
1153
|
+
True if email was sent successfully
|
|
1154
|
+
"""
|
|
1155
|
+
subject = f"Order Confirmed: {artist} - {title} | Nomad Karaoke"
|
|
1156
|
+
|
|
1157
|
+
extra_styles = """
|
|
1158
|
+
.order-details {
|
|
1159
|
+
background-color: #f8fafc;
|
|
1160
|
+
border-radius: 8px;
|
|
1161
|
+
padding: 20px;
|
|
1162
|
+
margin: 20px 0;
|
|
1163
|
+
}
|
|
1164
|
+
.order-details h3 {
|
|
1165
|
+
margin-top: 0;
|
|
1166
|
+
color: #1e293b;
|
|
1167
|
+
}
|
|
1168
|
+
.order-details p {
|
|
1169
|
+
margin: 8px 0;
|
|
1170
|
+
color: #475569;
|
|
1171
|
+
}
|
|
1172
|
+
.highlight {
|
|
1173
|
+
color: #059669;
|
|
1174
|
+
font-weight: bold;
|
|
1175
|
+
}
|
|
1176
|
+
"""
|
|
1177
|
+
|
|
1178
|
+
notes_html = ""
|
|
1179
|
+
notes_text = ""
|
|
1180
|
+
if notes:
|
|
1181
|
+
notes_html = f"<p><strong>Your Notes:</strong> {html.escape(notes)}</p>"
|
|
1182
|
+
notes_text = f"\nYour Notes: {notes}"
|
|
1183
|
+
|
|
1184
|
+
content = f"""
|
|
1185
|
+
<h2>Thank You for Your Order!</h2>
|
|
1186
|
+
|
|
1187
|
+
<div class="order-details">
|
|
1188
|
+
<h3>Order Details</h3>
|
|
1189
|
+
<p><strong>Order ID:</strong> {html.escape(job_id)}</p>
|
|
1190
|
+
<p><strong>Artist:</strong> {html.escape(artist)}</p>
|
|
1191
|
+
<p><strong>Title:</strong> {html.escape(title)}</p>
|
|
1192
|
+
{notes_html}
|
|
1193
|
+
</div>
|
|
1194
|
+
|
|
1195
|
+
<p>Our team will create your custom karaoke video within <span class="highlight">24 hours</span>.</p>
|
|
1196
|
+
|
|
1197
|
+
<p>You'll receive an email with download links as soon as your video is ready.</p>
|
|
1198
|
+
|
|
1199
|
+
<p><strong>No action needed</strong> - sit back and we'll take care of everything!</p>
|
|
1200
|
+
"""
|
|
1201
|
+
|
|
1202
|
+
text_content = f"""Thank You for Your Order!
|
|
1203
|
+
|
|
1204
|
+
Order Details:
|
|
1205
|
+
Order ID: {job_id}
|
|
1206
|
+
Artist: {artist}
|
|
1207
|
+
Title: {title}{notes_text}
|
|
1208
|
+
|
|
1209
|
+
Our team will create your custom karaoke video within 24 hours.
|
|
1210
|
+
|
|
1211
|
+
You'll receive an email with download links as soon as your video is ready.
|
|
1212
|
+
|
|
1213
|
+
No action needed - sit back and we'll take care of everything!
|
|
1214
|
+
"""
|
|
1215
|
+
|
|
1216
|
+
html_content = self._build_email_html(content, extra_styles)
|
|
1217
|
+
|
|
1218
|
+
return self.provider.send_email(
|
|
1219
|
+
to_email=to_email,
|
|
1220
|
+
subject=subject,
|
|
1221
|
+
html_content=html_content,
|
|
1222
|
+
text_content=text_content,
|
|
1223
|
+
bcc_emails=["done@nomadkaraoke.com"],
|
|
1224
|
+
)
|
|
1225
|
+
|
|
1226
|
+
def send_made_for_you_admin_notification(
|
|
1227
|
+
self,
|
|
1228
|
+
to_email: str,
|
|
1229
|
+
customer_email: str,
|
|
1230
|
+
artist: str,
|
|
1231
|
+
title: str,
|
|
1232
|
+
job_id: str,
|
|
1233
|
+
admin_login_token: str,
|
|
1234
|
+
notes: Optional[str] = None,
|
|
1235
|
+
audio_source_count: int = 0,
|
|
1236
|
+
) -> bool:
|
|
1237
|
+
"""
|
|
1238
|
+
Send notification email to admin for new made-for-you orders.
|
|
1239
|
+
|
|
1240
|
+
Args:
|
|
1241
|
+
to_email: Admin email address
|
|
1242
|
+
customer_email: Customer's email address
|
|
1243
|
+
artist: Artist name
|
|
1244
|
+
title: Song title
|
|
1245
|
+
job_id: Job ID for reference
|
|
1246
|
+
admin_login_token: Token for one-click admin login
|
|
1247
|
+
notes: Optional customer notes
|
|
1248
|
+
audio_source_count: Number of audio sources found
|
|
1249
|
+
|
|
1250
|
+
Returns:
|
|
1251
|
+
True if email was sent successfully
|
|
1252
|
+
"""
|
|
1253
|
+
subject = f"Karaoke Order: {artist} - {title} [ID: {job_id}]"
|
|
1254
|
+
|
|
1255
|
+
extra_styles = """
|
|
1256
|
+
.order-info {
|
|
1257
|
+
background-color: #f8fafc;
|
|
1258
|
+
border-radius: 8px;
|
|
1259
|
+
padding: 20px;
|
|
1260
|
+
margin: 20px 0;
|
|
1261
|
+
}
|
|
1262
|
+
.order-info p {
|
|
1263
|
+
margin: 8px 0;
|
|
1264
|
+
}
|
|
1265
|
+
.action-button {
|
|
1266
|
+
display: inline-block;
|
|
1267
|
+
background-color: #2563eb;
|
|
1268
|
+
color: white !important;
|
|
1269
|
+
padding: 12px 24px;
|
|
1270
|
+
border-radius: 6px;
|
|
1271
|
+
text-decoration: none;
|
|
1272
|
+
font-weight: bold;
|
|
1273
|
+
margin-top: 16px;
|
|
1274
|
+
}
|
|
1275
|
+
"""
|
|
1276
|
+
|
|
1277
|
+
notes_html = ""
|
|
1278
|
+
notes_text = ""
|
|
1279
|
+
if notes:
|
|
1280
|
+
notes_html = f"<p><strong>Customer Notes:</strong> {html.escape(notes)}</p>"
|
|
1281
|
+
notes_text = f"\nCustomer Notes: {notes}"
|
|
1282
|
+
|
|
1283
|
+
# Calculate deadline (24 hours from now, converted to Eastern Time)
|
|
1284
|
+
deadline_utc = datetime.now(timezone.utc) + timedelta(hours=24)
|
|
1285
|
+
eastern_tz = ZoneInfo("America/New_York")
|
|
1286
|
+
deadline_eastern = deadline_utc.astimezone(eastern_tz)
|
|
1287
|
+
# Use "ET" (Eastern Time) to be correct for both EST and EDT
|
|
1288
|
+
deadline_str = deadline_eastern.strftime("%B %d, %Y at %I:%M %p") + " ET"
|
|
1289
|
+
|
|
1290
|
+
# Link to /app/ with admin login token for one-click access
|
|
1291
|
+
app_url = f"{self.frontend_url.rstrip('/')}/app/?admin_token={admin_login_token}"
|
|
1292
|
+
|
|
1293
|
+
content = f"""
|
|
1294
|
+
<div class="order-info">
|
|
1295
|
+
<p><strong>Order / Job ID:</strong> {html.escape(job_id)}</p>
|
|
1296
|
+
<p><strong>Customer:</strong> {html.escape(customer_email)}</p>
|
|
1297
|
+
<p><strong>Artist:</strong> {html.escape(artist)}</p>
|
|
1298
|
+
<p><strong>Title:</strong> {html.escape(title)}</p>
|
|
1299
|
+
<p><strong>Audio Sources Found:</strong> {audio_source_count}</p>
|
|
1300
|
+
{notes_html}
|
|
1301
|
+
<p><strong>Deliver By:</strong> {deadline_str}</p>
|
|
1302
|
+
<p style="margin-top: 16px;"><strong>Action Required:</strong> Select an audio source to start processing.</p>
|
|
1303
|
+
<a href="{app_url}" class="action-button">Open Job</a>
|
|
1304
|
+
</div>
|
|
1305
|
+
"""
|
|
1306
|
+
|
|
1307
|
+
text_content = f"""New Karaoke Order
|
|
1308
|
+
|
|
1309
|
+
Order / Job ID: {job_id}
|
|
1310
|
+
Customer: {customer_email}
|
|
1311
|
+
Artist: {artist}
|
|
1312
|
+
Title: {title}
|
|
1313
|
+
Audio Sources Found: {audio_source_count}{notes_text}
|
|
1314
|
+
Deliver By: {deadline_str}
|
|
1315
|
+
|
|
1316
|
+
Action Required: Select an audio source to start processing.
|
|
1317
|
+
|
|
1318
|
+
Open Job: {app_url}
|
|
1319
|
+
"""
|
|
1320
|
+
|
|
1321
|
+
html_content = self._build_email_html(content, extra_styles)
|
|
1322
|
+
|
|
1323
|
+
return self.provider.send_email(
|
|
1324
|
+
to_email=to_email,
|
|
1325
|
+
subject=subject,
|
|
1326
|
+
html_content=html_content,
|
|
1327
|
+
text_content=text_content,
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1083
1330
|
|
|
1084
1331
|
# Global instance
|
|
1085
1332
|
_email_service = None
|
|
@@ -116,6 +116,7 @@ class FirestoreService:
|
|
|
116
116
|
created_after: Optional[datetime] = None,
|
|
117
117
|
created_before: Optional[datetime] = None,
|
|
118
118
|
user_email: Optional[str] = None,
|
|
119
|
+
tenant_id: Optional[str] = None,
|
|
119
120
|
limit: int = 100
|
|
120
121
|
) -> List[Job]:
|
|
121
122
|
"""
|
|
@@ -128,6 +129,7 @@ class FirestoreService:
|
|
|
128
129
|
created_after: Filter jobs created after this datetime
|
|
129
130
|
created_before: Filter jobs created before this datetime
|
|
130
131
|
user_email: Filter by user_email (owner of the job)
|
|
132
|
+
tenant_id: Filter by tenant_id (white-label portal scoping)
|
|
131
133
|
limit: Maximum number of jobs to return
|
|
132
134
|
|
|
133
135
|
Returns:
|
|
@@ -150,6 +152,10 @@ class FirestoreService:
|
|
|
150
152
|
if user_email:
|
|
151
153
|
query = query.where(filter=FieldFilter('user_email', '==', user_email.lower()))
|
|
152
154
|
|
|
155
|
+
# Filter by tenant_id (white-label portal scoping)
|
|
156
|
+
if tenant_id:
|
|
157
|
+
query = query.where(filter=FieldFilter('tenant_id', '==', tenant_id))
|
|
158
|
+
|
|
153
159
|
# Date range filters
|
|
154
160
|
if created_after:
|
|
155
161
|
query = query.where(filter=FieldFilter('created_at', '>=', created_after))
|
backend/services/job_manager.py
CHANGED
|
@@ -23,6 +23,16 @@ from backend.services.storage_service import StorageService
|
|
|
23
23
|
logger = logging.getLogger(__name__)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _mask_email(email: str) -> str:
|
|
27
|
+
"""Mask email for logging to protect PII. Shows first char + domain."""
|
|
28
|
+
if not email or "@" not in email:
|
|
29
|
+
return "***"
|
|
30
|
+
local, domain = email.split("@", 1)
|
|
31
|
+
if len(local) <= 1:
|
|
32
|
+
return f"*@{domain}"
|
|
33
|
+
return f"{local[0]}***@{domain}"
|
|
34
|
+
|
|
35
|
+
|
|
26
36
|
class JobManager:
|
|
27
37
|
"""Manager for job lifecycle and state."""
|
|
28
38
|
|
|
@@ -85,6 +95,12 @@ class JobManager:
|
|
|
85
95
|
keep_brand_code=job_create.keep_brand_code,
|
|
86
96
|
# Request metadata (for tracking and filtering)
|
|
87
97
|
request_metadata=job_create.request_metadata,
|
|
98
|
+
# Tenant scoping
|
|
99
|
+
tenant_id=job_create.tenant_id,
|
|
100
|
+
# Made-for-you order fields
|
|
101
|
+
made_for_you=job_create.made_for_you,
|
|
102
|
+
customer_email=job_create.customer_email,
|
|
103
|
+
customer_notes=job_create.customer_notes,
|
|
88
104
|
)
|
|
89
105
|
|
|
90
106
|
self.firestore.create_job(job)
|
|
@@ -125,6 +141,7 @@ class JobManager:
|
|
|
125
141
|
created_after: Optional[datetime] = None,
|
|
126
142
|
created_before: Optional[datetime] = None,
|
|
127
143
|
user_email: Optional[str] = None,
|
|
144
|
+
tenant_id: Optional[str] = None,
|
|
128
145
|
limit: int = 100
|
|
129
146
|
) -> List[Job]:
|
|
130
147
|
"""
|
|
@@ -137,6 +154,7 @@ class JobManager:
|
|
|
137
154
|
created_after: Filter jobs created after this datetime
|
|
138
155
|
created_before: Filter jobs created before this datetime
|
|
139
156
|
user_email: Filter by user_email (job owner)
|
|
157
|
+
tenant_id: Filter by tenant_id (white-label portal scoping)
|
|
140
158
|
limit: Maximum number of jobs to return
|
|
141
159
|
|
|
142
160
|
Returns:
|
|
@@ -149,6 +167,7 @@ class JobManager:
|
|
|
149
167
|
created_after=created_after,
|
|
150
168
|
created_before=created_before,
|
|
151
169
|
user_email=user_email,
|
|
170
|
+
tenant_id=tenant_id,
|
|
152
171
|
limit=limit
|
|
153
172
|
)
|
|
154
173
|
|
|
@@ -413,6 +432,7 @@ class JobManager:
|
|
|
413
432
|
"""
|
|
414
433
|
Schedule sending a job completion email.
|
|
415
434
|
|
|
435
|
+
For made-for-you orders, also transfers ownership from admin to customer.
|
|
416
436
|
Uses asyncio to fire-and-forget the email sending.
|
|
417
437
|
"""
|
|
418
438
|
import asyncio
|
|
@@ -429,11 +449,22 @@ class JobManager:
|
|
|
429
449
|
dropbox_url = state_data.get('dropbox_link')
|
|
430
450
|
brand_code = state_data.get('brand_code')
|
|
431
451
|
|
|
452
|
+
# For made-for-you orders, send to customer and transfer ownership
|
|
453
|
+
recipient_email = job.user_email
|
|
454
|
+
if job.made_for_you and job.customer_email:
|
|
455
|
+
recipient_email = job.customer_email
|
|
456
|
+
# Transfer ownership from admin to customer (non-blocking - email still goes out if this fails)
|
|
457
|
+
try:
|
|
458
|
+
self.update_job(job.job_id, {'user_email': job.customer_email})
|
|
459
|
+
logger.info(f"Transferred ownership of made-for-you job {job.job_id} to {_mask_email(job.customer_email)}")
|
|
460
|
+
except Exception as e:
|
|
461
|
+
logger.error(f"Failed to transfer ownership for job {job.job_id}: {e}")
|
|
462
|
+
|
|
432
463
|
# Create async task (fire-and-forget)
|
|
433
464
|
async def send_email():
|
|
434
465
|
await notification_service.send_job_completion_email(
|
|
435
466
|
job_id=job.job_id,
|
|
436
|
-
user_email=
|
|
467
|
+
user_email=recipient_email,
|
|
437
468
|
user_name=None, # Could fetch from user service if needed
|
|
438
469
|
artist=job.artist,
|
|
439
470
|
title=job.title,
|
|
@@ -46,11 +46,12 @@ CREDIT_PACKAGES = {
|
|
|
46
46
|
},
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
|
|
49
|
+
# Made-for-you service package (not a credit package - creates a job directly)
|
|
50
|
+
MADE_FOR_YOU_PACKAGE = {
|
|
51
51
|
"price_cents": 1500, # $15.00
|
|
52
|
-
"name": "
|
|
53
|
-
"description": "
|
|
52
|
+
"name": "Made For You Karaoke Video",
|
|
53
|
+
"description": "Professional 4K karaoke video with perfectly synced lyrics, delivered to your email within 24 hours",
|
|
54
|
+
"images": ["https://gen.nomadkaraoke.com/nomad-logo.png"],
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
|
|
@@ -65,6 +66,10 @@ class StripeService:
|
|
|
65
66
|
self.frontend_url = os.getenv("FRONTEND_URL", "https://gen.nomadkaraoke.com")
|
|
66
67
|
# After consolidation, buy URL is the same as frontend URL
|
|
67
68
|
self.buy_url = os.getenv("BUY_URL", self.frontend_url)
|
|
69
|
+
# Payment method configuration ID from Stripe Dashboard
|
|
70
|
+
# Enables Google Pay, Apple Pay, Link, and other wallet methods
|
|
71
|
+
# Get this from: Dashboard > Settings > Payment methods > [Your Config] > Copy ID
|
|
72
|
+
self.payment_method_config = os.getenv("STRIPE_PAYMENT_METHOD_CONFIG")
|
|
68
73
|
|
|
69
74
|
if self.secret_key:
|
|
70
75
|
stripe.api_key = self.secret_key
|
|
@@ -72,6 +77,11 @@ class StripeService:
|
|
|
72
77
|
else:
|
|
73
78
|
logger.warning("STRIPE_SECRET_KEY not set - payments disabled")
|
|
74
79
|
|
|
80
|
+
if self.payment_method_config:
|
|
81
|
+
logger.info(f"Using payment method config: {self.payment_method_config}")
|
|
82
|
+
else:
|
|
83
|
+
logger.info("No STRIPE_PAYMENT_METHOD_CONFIG set - using Stripe defaults")
|
|
84
|
+
|
|
75
85
|
def is_configured(self) -> bool:
|
|
76
86
|
"""Check if Stripe is properly configured."""
|
|
77
87
|
return bool(self.secret_key)
|
|
@@ -113,32 +123,40 @@ class StripeService:
|
|
|
113
123
|
if not cancel_url:
|
|
114
124
|
cancel_url = f"{self.buy_url}?cancelled=true"
|
|
115
125
|
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
payment_method_types
|
|
119
|
-
line_items
|
|
126
|
+
# Build checkout session params
|
|
127
|
+
session_params = {
|
|
128
|
+
# Omit payment_method_types to auto-enable Apple Pay, Google Pay, Link, etc.
|
|
129
|
+
'line_items': [{
|
|
120
130
|
'price_data': {
|
|
121
131
|
'currency': 'usd',
|
|
122
132
|
'product_data': {
|
|
123
133
|
'name': package['name'],
|
|
124
134
|
'description': package['description'],
|
|
135
|
+
'images': ['https://gen.nomadkaraoke.com/nomad-logo.png'],
|
|
125
136
|
},
|
|
126
137
|
'unit_amount': package['price_cents'],
|
|
127
138
|
},
|
|
128
139
|
'quantity': 1,
|
|
129
140
|
}],
|
|
130
|
-
mode
|
|
131
|
-
success_url
|
|
132
|
-
cancel_url
|
|
133
|
-
customer_email
|
|
134
|
-
metadata
|
|
141
|
+
'mode': 'payment',
|
|
142
|
+
'success_url': success_url,
|
|
143
|
+
'cancel_url': cancel_url,
|
|
144
|
+
'customer_email': user_email,
|
|
145
|
+
'metadata': {
|
|
135
146
|
'package_id': package_id,
|
|
136
147
|
'credits': str(package['credits']),
|
|
137
148
|
'user_email': user_email,
|
|
138
149
|
},
|
|
139
150
|
# Allow promotion codes
|
|
140
|
-
allow_promotion_codes
|
|
141
|
-
|
|
151
|
+
'allow_promotion_codes': True,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Add payment method configuration if set (enables Google Pay, Link, etc.)
|
|
155
|
+
if self.payment_method_config:
|
|
156
|
+
session_params['payment_method_configuration'] = self.payment_method_config
|
|
157
|
+
|
|
158
|
+
# Create checkout session
|
|
159
|
+
session = stripe.checkout.Session.create(**session_params)
|
|
142
160
|
|
|
143
161
|
logger.info(f"Created checkout session {session.id} for {user_email}, package {package_id}")
|
|
144
162
|
return True, session.url, "Checkout session created"
|
|
@@ -150,7 +168,7 @@ class StripeService:
|
|
|
150
168
|
logger.error(f"Error creating checkout session: {e}")
|
|
151
169
|
return False, None, "Failed to create checkout session"
|
|
152
170
|
|
|
153
|
-
def
|
|
171
|
+
def create_made_for_you_checkout_session(
|
|
154
172
|
self,
|
|
155
173
|
customer_email: str,
|
|
156
174
|
artist: str,
|
|
@@ -162,7 +180,7 @@ class StripeService:
|
|
|
162
180
|
cancel_url: Optional[str] = None,
|
|
163
181
|
) -> Tuple[bool, Optional[str], str]:
|
|
164
182
|
"""
|
|
165
|
-
Create a Stripe Checkout session for a
|
|
183
|
+
Create a Stripe Checkout session for a made-for-you order.
|
|
166
184
|
|
|
167
185
|
This is for the full-service karaoke video creation where Nomad Karaoke
|
|
168
186
|
handles all the work (lyrics review, instrumental selection, etc.).
|
|
@@ -192,7 +210,7 @@ class StripeService:
|
|
|
192
210
|
|
|
193
211
|
# Build metadata for job creation after payment
|
|
194
212
|
metadata = {
|
|
195
|
-
'order_type': '
|
|
213
|
+
'order_type': 'made_for_you',
|
|
196
214
|
'customer_email': customer_email,
|
|
197
215
|
'artist': artist,
|
|
198
216
|
'title': title,
|
|
@@ -204,39 +222,47 @@ class StripeService:
|
|
|
204
222
|
# Truncate notes to fit Stripe's 500 char limit per metadata value
|
|
205
223
|
metadata['notes'] = notes[:500] if len(notes) > 500 else notes
|
|
206
224
|
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
payment_method_types
|
|
210
|
-
line_items
|
|
225
|
+
# Build checkout session params
|
|
226
|
+
session_params = {
|
|
227
|
+
# Omit payment_method_types to auto-enable Apple Pay, Google Pay, Link, etc.
|
|
228
|
+
'line_items': [{
|
|
211
229
|
'price_data': {
|
|
212
230
|
'currency': 'usd',
|
|
213
231
|
'product_data': {
|
|
214
|
-
'name':
|
|
215
|
-
'description':
|
|
232
|
+
'name': f"Karaoke Video: {artist} - {title}",
|
|
233
|
+
'description': MADE_FOR_YOU_PACKAGE['description'],
|
|
234
|
+
'images': MADE_FOR_YOU_PACKAGE['images'],
|
|
216
235
|
},
|
|
217
|
-
'unit_amount':
|
|
236
|
+
'unit_amount': MADE_FOR_YOU_PACKAGE['price_cents'],
|
|
218
237
|
},
|
|
219
238
|
'quantity': 1,
|
|
220
239
|
}],
|
|
221
|
-
mode
|
|
222
|
-
success_url
|
|
223
|
-
cancel_url
|
|
224
|
-
customer_email
|
|
225
|
-
metadata
|
|
226
|
-
allow_promotion_codes
|
|
227
|
-
|
|
240
|
+
'mode': 'payment',
|
|
241
|
+
'success_url': success_url,
|
|
242
|
+
'cancel_url': cancel_url,
|
|
243
|
+
'customer_email': customer_email,
|
|
244
|
+
'metadata': metadata,
|
|
245
|
+
'allow_promotion_codes': True,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Add payment method configuration if set (enables Google Pay, Link, etc.)
|
|
249
|
+
if self.payment_method_config:
|
|
250
|
+
session_params['payment_method_configuration'] = self.payment_method_config
|
|
251
|
+
|
|
252
|
+
# Create checkout session
|
|
253
|
+
session = stripe.checkout.Session.create(**session_params)
|
|
228
254
|
|
|
229
255
|
logger.info(
|
|
230
|
-
f"Created
|
|
256
|
+
f"Created made-for-you checkout session {session.id} for {customer_email}, "
|
|
231
257
|
f"song: {artist} - {title}"
|
|
232
258
|
)
|
|
233
259
|
return True, session.url, "Checkout session created"
|
|
234
260
|
|
|
235
261
|
except stripe.error.StripeError as e:
|
|
236
|
-
logger.error(f"Stripe error creating
|
|
262
|
+
logger.error(f"Stripe error creating made-for-you checkout session: {e}")
|
|
237
263
|
return False, None, f"Payment error: {str(e)}"
|
|
238
264
|
except Exception as e:
|
|
239
|
-
logger.error(f"Error creating
|
|
265
|
+
logger.error(f"Error creating made-for-you checkout session: {e}")
|
|
240
266
|
return False, None, "Failed to create checkout session"
|
|
241
267
|
|
|
242
268
|
def verify_webhook_signature(self, payload: bytes, signature: str) -> Tuple[bool, Optional[Dict], str]:
|