diff --git a/CLAUDE.md b/CLAUDE.md index de3a48d..d6c9d19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Architecture +A note about this codebase: This was originally taken from Auth0 and messages_views.py and messages_service.py were the original files we built from. Over time we have created a better structure for this backend service. Please do not update these two files anymore, you should consider the other code that exists first, and then add a new _views.py and a new _service.py if you're unable to find a good place to update the code as it relates to the file. If you find code in these two "messages" files that you plan to update, it's encouraged to migrate that code elsewhere to help iteratively move away from messages_views and messages_service. + ### Flask app with Blueprint-based API modules The app is created via `api/__init__.py:create_app()`. Each API domain is a Blueprint registered there. The WSGI entrypoint is `api/wsgi.py`. @@ -117,6 +119,13 @@ To send a Slack DM, pass the Slack user ID as `channel`: `send_slack(message=... These are DIFFERENT VALUES. When bundling user data for the frontend, include both: `{user_id: propel_id, db_id: firestore_doc_id, name, profile_image}`. The frontend needs `db_id` to build profile links and `user_id` (propel) for matching against assignees/editors/mentions. +## Admin Email Templates (`email_templates` collection) +Powers the frontend `/admin/communication` template editor and the volunteer send-email dialogs. Blueprint: `api/email_templates/email_templates_views.py` (`/api/admin/templates*`, all `volunteer.admin`-gated); service: `services/email_templates_service.py`; seed data: `services/email_templates_seed.py`. +- Layout: `email_templates/{slug}` main doc + `email_templates/{slug}/versions/{000N}` append-only content snapshots. `version` on the main doc = current version number; version doc ids are zero-padded for natural ordering. +- Versioning rules: content-key edits (`title/category/category_key/applicable_roles/message/icon`) bump version + append a snapshot; status-only patches don't bump; **revert never rewrites history** — it copies the target version's content forward as a NEW version (`change_note: "Reverted to version N"`). +- Seeding: `email_templates_seed.py` holds the original 22 hardcoded frontend templates (GENERATED from frontend `src/lib/messageTemplates.js` — regenerate, don't hand-edit; editing it does not change live emails). Auto-seeds on first list call when the collection is empty; `POST /api/admin/templates/seed` re-inserts missing seed docs only — never overwrites admin edits. +- DELETE is a hard delete (doc + versions). Deleted seed templates can be restored via /seed (history restarts at v1). + ## Hackathon roster import + audit scripts Two scripts live in `scripts/` for diagnosing and backfilling team rosters on `/hack/`: diff --git a/api/__init__.py b/api/__init__.py index 98b5a47..b184d43 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -186,6 +186,7 @@ def add_headers(response): from api.store import store_views from api.planning import planning_views from api.mentors import mentors_views + from api.email_templates import email_templates_views app.register_blueprint(messages_views.bp) app.register_blueprint(exception_views.bp) @@ -207,5 +208,6 @@ def add_headers(response): app.register_blueprint(store_views.bp) app.register_blueprint(planning_views.bp) app.register_blueprint(mentors_views.bp) + app.register_blueprint(email_templates_views.bp) return app diff --git a/api/email_templates/__init__.py b/api/email_templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/email_templates/email_templates_views.py b/api/email_templates/email_templates_views.py new file mode 100644 index 0000000..4ad7979 --- /dev/null +++ b/api/email_templates/email_templates_views.py @@ -0,0 +1,97 @@ +"""Admin email template CRUD with version history. + +Powers the /admin/communication template editor on the frontend. Templates +live in the email_templates collection (see services/email_templates_service) +with an append-only versions subcollection for history/revert. Seeded from +the original hardcoded frontend templates on first list call. +""" + +from flask import Blueprint, request + +from common.log import get_logger +from common.auth import auth, auth_user, getOrgId +from services.email_templates_service import ( + admin_list_templates, + admin_create_template, + admin_update_template, + admin_delete_template, + admin_get_template_versions, + admin_revert_template, + admin_seed_templates, +) + +logger = get_logger(__name__) + +bp = Blueprint("email_templates", __name__, url_prefix="/api") + + +def _actor_from_request(): + try: + return { + "propel_user_id": auth_user.user_id if auth_user else None, + "email": getattr(auth_user, "email", None) if auth_user else None, + } + except Exception: + return None + + +@bp.route("/admin/templates", methods=["GET"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_get_templates(): + logger.info("GET /admin/templates called") + return vars(admin_list_templates()) + + +@bp.route("/admin/templates", methods=["POST"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_post_template(): + logger.info("POST /admin/templates called") + msg, status_code = admin_create_template(request.get_json(), _actor_from_request()) + return vars(msg), status_code + + +@bp.route("/admin/templates/seed", methods=["POST"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_post_template_seed(): + logger.info("POST /admin/templates/seed called") + msg, status_code = admin_seed_templates() + return vars(msg), status_code + + +@bp.route("/admin/templates/", methods=["PATCH"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_patch_template(template_id): + logger.info(f"PATCH /admin/templates/{template_id} called") + msg, status_code = admin_update_template(template_id, request.get_json(), _actor_from_request()) + return vars(msg), status_code + + +@bp.route("/admin/templates/", methods=["DELETE"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_delete_template_route(template_id): + logger.info(f"DELETE /admin/templates/{template_id} called") + msg, status_code = admin_delete_template(template_id) + return vars(msg), status_code + + +@bp.route("/admin/templates//versions", methods=["GET"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_get_template_versions_route(template_id): + logger.info(f"GET /admin/templates/{template_id}/versions called") + msg, status_code = admin_get_template_versions(template_id) + return vars(msg), status_code + + +@bp.route("/admin/templates//revert", methods=["POST"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_post_template_revert(template_id): + logger.info(f"POST /admin/templates/{template_id}/revert called") + msg, status_code = admin_revert_template(template_id, request.get_json(), _actor_from_request()) + return vars(msg), status_code diff --git a/services/email_templates_seed.py b/services/email_templates_seed.py new file mode 100644 index 0000000..01723f7 --- /dev/null +++ b/services/email_templates_seed.py @@ -0,0 +1,612 @@ +"""Seed data for the email_templates Firestore collection. + +These are the original hardcoded templates from the frontend's +src/lib/messageTemplates.js (MESSAGE_TEMPLATES). They are inserted into the +email_templates collection on first use (and by the explicit re-seed action) +so the database retains the original versions. After seeding, the database is +the source of truth -- edits happen via /api/messages/admin/templates and are +version-tracked. Do NOT edit message text here to change live emails; this +file only defines what a fresh seed/restore produces. + +Generated from messageTemplates.js -- regenerate rather than hand-editing. +""" + +DEFAULT_EMAIL_TEMPLATES = [{'id': 'hacker_approved', + 'title': 'Hacker Application Approved', + 'category_key': 'APPROVAL', + 'category': 'Approval & Confirmation', + 'applicable_roles': ['hacker', 'hackers'], + 'message': "šŸŽ‰ You're in! Welcome to Opportunity Hack!\n" + '\n' + 'Your hacker application is approved. Get ready to build impactful solutions for ' + 'nonprofits alongside amazing teammates.\n' + '\n' + 'šŸš€ Next steps:\n' + '• Join our Slack for updates\n' + '• Wait for us to announce nonprofit projects at ' + 'https://www.ohack.dev/hack/[EVENT_ID]\n' + "• Learn what's expected: https://www.ohack.dev/about/hackers\n" + '• Understand judging criteria: https://www.ohack.dev/about/judges\n' + '• Optional: Track your volunteer hours (if you want to keep track): ' + 'https://www.ohack.dev/volunteer/track\n' + '\n' + "Let's change the world, one line of code at a time! šŸ’»\n" + '\n' + 'Stay connected: @opportunityhack on all socials', + 'icon': 'āœ…'}, + {'id': 'mentor_approved', + 'title': 'Mentor Application Approved', + 'category_key': 'APPROVAL', + 'category': 'Approval & Confirmation', + 'applicable_roles': ['mentor', 'mentors'], + 'message': '🌟 Welcome to our mentor squad!\n' + '\n' + 'Your expertise will guide teams to create life-changing solutions for nonprofits. ' + 'Thank you for sharing your knowledge!\n' + '\n' + 'šŸ“š Resources:\n' + '• Mentor guide: https://www.ohack.dev/about/mentors\n' + '• **Remote mentors only**: Check in at [mentor check-in ' + 'portal](https://www.ohack.dev/hack/[EVENT_ID]/mentor-checkin)\n' + '• **In-person mentors**: Use your QR code at the venue for check-in\n' + '• Optional: Track your volunteer hours (if you want to keep track): ' + 'https://www.ohack.dev/volunteer/track\n' + '\n' + 'Ready to inspire the next generation of changemakers? šŸš€', + 'icon': 'šŸŽÆ'}, + {'id': 'judge_travel_confirmation', + 'title': 'Judge Application Approved - Please Confirm Travel', + 'category_key': 'APPROVAL', + 'category': 'Approval & Confirmation', + 'applicable_roles': ['judge', 'judges'], + 'message': 'āš–ļø Congratulations! Your judge application has been approved!\n' + '\n' + "We're excited to have you evaluate the innovative solutions our teams will create " + 'for nonprofits. However, we need your confirmation for an important detail:\n' + '\n' + 'āœˆļø **All judging for Opportunity Hack is done IN PERSON at [LOCATION_NAME].**\n' + '\n' + 'šŸ“ Location Details:\n' + '• Event location: [LOCATION_NAME]\n' + '• More info & hotel recommendations: [LOCATION_URL]\n' + '• Full schedule of events: https://www.ohack.dev/hack/[EVENT_ID]#countdown\n' + '• Add yourself to our LinkedIn event: [LINKEDIN_EVENT_URL]\n' + '\n' + 'ā° **ACTION REQUIRED by [RSVP_DEADLINE]:**\n' + 'Please reply to this email at questions@ohack.org to confirm:\n' + 'āœ… "I confirm I can attend in person at [LOCATION_NAME]" OR\n' + 'āŒ "I need to decline due to travel constraints"\n' + '\n' + 'āœļø **Need to edit your application?**\n' + 'Go to: https://www.ohack.dev/hack/[EVENT_ID]/judge-application\n' + 'Use code: "[ACCESS_CODE]"\n' + '\n' + 'We understand travel requirements may not work for everyone. We just need to know by ' + 'the deadline to finalize our judging panel.\n' + '\n' + 'Thank you for your interest in supporting nonprofit innovation! 🌟', + 'icon': 'āœˆļø'}, + {'id': 'judge_approved', + 'title': 'Judge Application Approved', + 'category_key': 'APPROVAL', + 'category': 'Approval & Confirmation', + 'applicable_roles': ['judge', 'judges'], + 'message': 'āš–ļø Welcome to our judging panel!\n' + '\n' + 'Thank you for being here! Having your talent and background to review these projects ' + 'helps us to find the top teams who have solved problems for nonprofits.\n' + '\n' + 'šŸ“‹ Resources & Next Steps:\n' + '1. Judging Intro [video](https://youtu.be/YM8j-2CA-mE?si=WNiRqI9Ww_Jd0yx0)\n' + "2. When the projects have closed and we're ready to judge, you'll go " + '[here](https://www.ohack.dev/judge)\n' + '3. Judging criteria is [here](https://www.ohack.dev/about/judges)\n' + '4. You can already start reviewing teams GitHub and DevPost now (knowing that they ' + 'might land more changes before the end of the hack) all teams are listed ' + '[here](https://www.ohack.dev/hack/[EVENT_ID]#teams)\n' + '5. All judges are listed [here](https://www.ohack.dev/hack/[EVENT_ID]#judge)\n' + '6. Take time to say hi and introduce yourself to everyone, this is a great way to ' + 'market amongst similar-minded, community focused people\n' + '\n' + 'ā±ļø Track your impact: https://www.ohack.dev/volunteer/track\n' + '\n' + 'Ready to discover amazing innovations! ✨', + 'icon': 'āš–ļø'}, + {'id': 'volunteer_approved', + 'title': 'Volunteer Application Approved', + 'category_key': 'APPROVAL', + 'category': 'Approval & Confirmation', + 'applicable_roles': ['volunteer', 'volunteers'], + 'message': "šŸ™Œ You're part of the dream team!\n" + '\n' + 'Thank you for helping make Opportunity Hack magical. Every volunteer contribution ' + 'creates ripple effects of positive change.\n' + '\n' + 'ā±ļø Track your impact: https://www.ohack.dev/volunteer/track\n' + '\n' + 'Assignments coming soon. Ready to be part of something amazing? 🌟', + 'icon': 'šŸ™Œ'}, + {'id': 'sponsor_approved', + 'title': 'Sponsorship Approved', + 'category_key': 'APPROVAL', + 'category': 'Approval & Confirmation', + 'applicable_roles': ['sponsor', 'sponsors'], + 'message': 'šŸ¤ Partnership activated!\n' + '\n' + "Thank you for investing in nonprofit innovation. Together, we're amplifying social " + 'impact through technology.\n' + '\n' + 'šŸ“ˆ Your support enables:\n' + '• Free participation for nonprofits\n' + '• Quality mentorship and resources\n' + '• Lasting solutions for communities\n' + '\n' + 'ā±ļø Team volunteering? Track at: https://www.ohack.dev/volunteer/track', + 'icon': 'šŸ¤'}, + {'id': 'checkin_information_mentors', + 'title': 'Check-in Information', + 'category_key': 'APPROVAL', + 'category': 'Approval & Confirmation', + 'applicable_roles': ['mentor', 'mentors'], + 'message': "šŸ“± Ready for check-in? Here's everything you need!\n" + '\n' + "For in-person participants, we've made check-in super easy with your personal QR " + 'code below **and also bring your identification**:\n' + '\n' + '[QRCode:[EVENT_ID]|[VOLUNTEER_ID]|[VOLUNTEER_TYPE]]\n' + '\n' + '**How to use your QR code:**\n' + '• Simply show this QR code when you arrive at the venue\n' + '• Our volunteers will scan it for instant check-in\n' + '• No need to remember names, emails, or confirmation numbers!\n' + '\n' + '**Remote mentors**: Use the [mentor check-in ' + 'portal](https://www.ohack.dev/hack/[EVENT_ID]/mentor-checkin) to check in virtually\n' + '\n' + "**Can't see the QR code?** No worries! You can also access it anytime from your " + 'application page:\n' + '[View Your ' + 'Application](https://www.ohack.dev/hack/[EVENT_ID]/[VOLUNTEER_TYPE]-application)\n' + '\n' + 'šŸ“ **Venue Information:**\n' + 'Get directions, parking details, and venue specifics at:\n' + '[ASU Tempe Location ' + 'Details](https://www.ohack.dev/about/locations/asu-tempe-arizona)\n' + '\n' + 'šŸŽÆ **What to bring:**\n' + '• This QR code (screenshot or bookmark this email)\n' + '• Your laptop and charger\n' + '• Enthusiasm for making an impact!\n' + '\n' + 'See you soon! šŸš€', + 'icon': 'šŸ“±'}, + {'id': 'checkin_information', + 'title': 'Check-in Information', + 'category_key': 'APPROVAL', + 'category': 'Approval & Confirmation', + 'applicable_roles': ['hacker', 'hackers', 'judge', 'judges', 'volunteer', 'volunteers'], + 'message': "šŸ“± Ready for check-in? Here's everything you need!\n" + '\n' + "For in-person participants, we've made check-in super easy with your personal QR " + 'code below **and also bring your identification**:\n' + '\n' + '[QRCode:[EVENT_ID]|[VOLUNTEER_ID]|[VOLUNTEER_TYPE]]\n' + '\n' + '**How to use your QR code:**\n' + '• Simply show this QR code when you arrive at the venue\n' + '• Our volunteers will scan it for instant check-in\n' + '• No need to remember names, emails, or confirmation numbers!\n' + '\n' + "**Can't see the QR code?** No worries! You can also access it anytime from your " + 'application page:\n' + '[View Your ' + 'Application](https://www.ohack.dev/hack/[EVENT_ID]/[VOLUNTEER_TYPE]-application)\n' + '\n' + 'šŸ“ **Venue Information:**\n' + 'Get directions, parking details, and venue specifics at:\n' + '[ASU Tempe Location ' + 'Details](https://www.ohack.dev/about/locations/asu-tempe-arizona)\n' + '\n' + 'šŸŽÆ **What to bring:**\n' + '• This QR code (screenshot or bookmark this email)\n' + '• Your laptop and charger\n' + '• Enthusiasm for making an impact!\n' + '\n' + 'See you soon! šŸš€', + 'icon': 'šŸ“±'}, + {'id': 'judge_application_denied', + 'title': 'Judge Application - Alternative Opportunity Available!', + 'category_key': 'DENIAL', + 'category': 'Application Denial', + 'applicable_roles': ['judge', 'judges'], + 'message': 'Thank you for your interest in judging at Opportunity Hack! šŸ™\n' + '\n' + 'While our judging panel is at capacity for this event, we have an exciting ' + 'alternative that offers even more meaningful volunteer experience and community ' + 'impact.\n' + '\n' + '🌟 **Consider becoming a MENTOR instead!**\n' + '\n' + "Here's why mentoring might be perfect for you:\n" + '• **More volunteer hours** - Mentors typically contribute 8-12 hours vs 2-4 for ' + 'judges\n' + '• **Direct community impact** - Guide teams solving real nonprofit problems\n' + '• **Professional development** - Share your expertise while learning from diverse ' + 'teams\n' + '• **Networking opportunities** - Work closely with passionate developers and ' + 'nonprofit leaders\n' + '• **Recognition** - All mentor contributions are documented for professional/visa ' + 'purposes\n' + '• **šŸ  Remote-friendly** - Mentor virtually from anywhere! No travel required - ' + 'support teams through Slack, video calls, and code reviews\n' + '• **Flexible schedule** - Choose when and how much you engage throughout the ' + 'hackathon weekend\n' + '• **Deeper relationships** - Build lasting connections with teams as you guide their ' + 'entire project journey\n' + '\n' + 'šŸ“ **Ready to make an even bigger impact?**\n' + 'Apply to be a mentor: [Mentor ' + 'Application](https://www.ohack.dev/hack/[EVENT_ID]/mentor-application)\n' + '\n' + 'ā±ļø All mentoring hours can be tracked at: https://www.ohack.dev/volunteer/track\n' + '\n' + 'šŸš€ **Still want to judge future events?** Keep an eye out for our next hackathon ' + 'announcements!\n' + '\n' + "Your expertise can transform ideas into lasting solutions. We'd love to have you on " + 'our mentor team! šŸ’”\n' + '\n' + 'Stay connected: @opportunityhack on all socials', + 'icon': 'šŸŽÆ'}, + {'id': 'application_denied', + 'title': 'Application Not Approved', + 'category_key': 'DENIAL', + 'category': 'Application Denial', + 'applicable_roles': ['mentor', + 'mentors', + 'volunteer', + 'volunteers', + 'hacker', + 'hackers', + 'sponsor', + 'sponsors'], + 'message': 'Thank you for wanting to join our mission! šŸ™\n' + '\n' + "While we can't accommodate your application this time due to capacity, your interest " + 'in helping nonprofits means everything.\n' + '\n' + '🌟 Stay involved:\n' + '• Apply for future events\n' + '• Follow @opportunityhack for opportunities\n' + '• Share our mission with your network\n' + '\n' + 'Every action towards social good counts. We hope to work together soon! šŸ’«', + 'icon': 'šŸ’«'}, + {'id': 'hacker_waitlisted', + 'title': "You're on the Waitlist - Stay Tuned!", + 'category_key': 'WAITLIST', + 'category': 'Waitlist Management', + 'applicable_roles': ['hacker', 'hackers'], + 'message': "ā³ You're on our hacker waitlist!\n" + '\n' + "Thank you for your interest in Opportunity Hack! While we've reached capacity for " + "initial registrations, we've added you to our waitlist.\n" + '\n' + 'šŸ”„ **What happens next:**\n' + "• We'll complete check-in process at 9:00 AM on event day\n" + "• If spots open up, you'll get an immediate notification\n" + '• Keep your phone handy and stay ready to join!\n' + '\n' + 'šŸŽ’ **Stay prepared:**\n' + '• Keep your laptop charged and ready\n' + '• Review the nonprofit projects: https://www.ohack.dev/hack/[EVENT_ID]#nonprofits\n' + '• Join our Slack for real-time updates\n' + '• Track your preparation time: https://www.ohack.dev/volunteer/track\n' + '\n' + 'We appreciate your patience and enthusiasm for nonprofit innovation. Whether you ' + "join us this time or next, you're already part of our community! 🌟\n" + '\n' + 'Stay connected: @opportunityhack on all socials', + 'icon': 'ā³'}, + {'id': 'hacker_waitlist_accepted', + 'title': "šŸŽ‰ You're In! Come Join Us Now!", + 'category_key': 'WAITLIST', + 'category': 'Waitlist Management', + 'applicable_roles': ['hacker', 'hackers'], + 'message': "šŸŽ‰ Amazing news - you're off the waitlist and INTO the hackathon!\n" + '\n' + 'A spot just opened up and we want YOU to fill it! Time to grab your laptop and join ' + 'us for an incredible day of building solutions for nonprofits.\n' + '\n' + 'šŸ“± **Your Check-in QR Code:**\n' + '[QRCode:[EVENT_ID]|[VOLUNTEER_ID]|[VOLUNTEER_TYPE]]\n' + '\n' + "šŸƒ\u200dā™‚ļø **Come NOW - Here's what to do:**\n" + '• Head to the venue immediately\n' + '• Bring this QR code for instant check-in\n' + '• Get your laptop, charger, and enthusiasm ready!\n' + '\n' + 'šŸ“ **Venue Information:**\n' + '[ASU Tempe Location ' + 'Details](https://www.ohack.dev/about/locations/asu-tempe-arizona)\n' + '\n' + "šŸŽÆ **What's happening:**\n" + '• Team formation is in progress\n' + '• Nonprofit presentations are starting soon\n' + '• Amazing prizes and impact awaiting!\n' + '\n' + "**Can't see the QR code?** Access it anytime at:\n" + '[Your Application](https://www.ohack.dev/hack/[EVENT_ID]/hacker-application)\n' + '\n' + 'ā±ļø Track your impact: https://www.ohack.dev/volunteer/track\n' + '\n' + "Let's build something incredible together! šŸš€", + 'icon': 'šŸŽ‰'}, + {'id': 'hacker_waitlist_full', + 'title': 'Waitlist Update - This Event is Full', + 'category_key': 'WAITLIST', + 'category': 'Waitlist Management', + 'applicable_roles': ['hacker', 'hackers'], + 'message': 'šŸ’™ Thank you for your interest in Opportunity Hack!\n' + '\n' + "We've completed our check-in process and unfortunately don't have any remaining " + "spots available for today's hackathon. We truly appreciate your enthusiasm and " + 'patience.\n' + '\n' + "🌟 **You're still part of our community:**\n" + '• Follow us for future hackathon announcements\n' + '• Join our Slack to stay connected with the community\n' + '• Consider other ways to get involved with nonprofits year-round\n' + '• Track any volunteer hours: https://www.ohack.dev/volunteer/track\n' + '\n' + 'šŸ“… **Future opportunities:**\n' + '• We host multiple hackathons throughout the year\n' + '• Volunteer opportunities at future events\n' + '• Mentor roles for experienced developers\n' + '• Stay updated on all events at https://www.ohack.dev\n' + '\n' + 'šŸ’” **Get involved now:**\n' + '• Share our mission with your network\n' + '• Follow our social impact stories\n' + '• Connect with nonprofits in your area\n' + '\n' + 'Your interest in using technology for social good means everything to us. We hope to ' + 'hack together at a future event! šŸ’«\n' + '\n' + 'Stay connected: @opportunityhack on all socials', + 'icon': 'šŸ’™'}, + {'id': 'sponsor_info_request', + 'title': 'Sponsor Information Request', + 'category_key': 'FOLLOW_UP', + 'category': 'Follow-up & Information', + 'applicable_roles': ['sponsor', 'sponsors'], + 'message': 'Excited about your sponsorship interest! šŸš€\n' + '\n' + "Let's create a partnership that amplifies your impact and aligns with your values.\n" + '\n' + 'šŸ’­ Quick questions:\n' + '• Preferred involvement level?\n' + "• Specific causes you're passionate about?\n" + '• Would your team like to volunteer?\n' + '\n' + 'ā±ļø Team volunteers can track time: https://www.ohack.dev/volunteer/track\n' + '\n' + "Reply with your thoughts - we'll craft the perfect partnership! ✨", + 'icon': 'šŸ“‹'}, + {'id': 'mentor_checkin_reminder', + 'title': 'Mentor Check-in Reminder', + 'category_key': 'FOLLOW_UP', + 'category': 'Follow-up & Information', + 'applicable_roles': ['mentor', 'mentors'], + 'message': 'Time to check in! šŸ‘‹\n' + '\n' + 'Your guidance is transforming ideas into impact. Quick reminder:\n' + '\n' + 'āœ… Check-in: https://www.ohack.dev/hack/[EVENT_ID]/mentor-checkin\n' + 'šŸ“š Resources: https://www.ohack.dev/about/mentors\n' + 'ā±ļø Track time: https://www.ohack.dev/volunteer/track\n' + '\n' + 'Every minute you spend mentoring creates lasting change! 🌟', + 'icon': 'ā°'}, + {'id': 'judge_info_sharing', + 'title': 'Judge Information & Resources', + 'category_key': 'FOLLOW_UP', + 'category': 'Follow-up & Information', + 'applicable_roles': ['judge', 'judges'], + 'message': 'Ready to spot game-changing solutions? āš–ļø\n' + '\n' + 'Your expertise helps identify innovations that will transform nonprofit work.\n' + '\n' + 'šŸ“š Resources:\n' + '0. Dates and times are [here on the hackathon ' + 'page](https://www.ohack.dev/hack/[EVENT_ID]#countdown)\n' + '1. Judging Intro [video](https://youtu.be/YM8j-2CA-mE?si=WNiRqI9Ww_Jd0yx0)\n' + "2. When the projects have closed and we're ready to judge, you'll go " + '[here](https://www.ohack.dev/judge)\n' + '3. Judging criteria is [here](https://www.ohack.dev/about/judges)\n' + '4. You can already start reviewing teams GitHub and DevPost now (knowing that they ' + 'might land more changes before the end of the hack) all teams are listed ' + '[here](https://www.ohack.dev/hack/[EVENT_ID]#teams)\n' + '5. All judges are listed [here](https://www.ohack.dev/hack/[EVENT_ID]#judge)\n' + '6. Take time to say hi and introduce yourself to everyone, this is a great way to ' + 'market amongst similar-minded, community focused people, join our judges Slack ' + 'channel: [SLACK_CHANNEL]\n' + '\n' + 'ā±ļø Track your volunteer hours: https://www.ohack.dev/volunteer/track\n' + '\n' + 'Get excited to discover the next big breakthrough! šŸŽÆ', + 'icon': 'šŸ“š'}, + {'id': 'volunteer_time_tracking', + 'title': 'Volunteer Time Tracking Reminder', + 'category_key': 'FOLLOW_UP', + 'category': 'Follow-up & Information', + 'applicable_roles': ['mentor', + 'mentors', + 'judge', + 'judges', + 'volunteer', + 'volunteers', + 'hacker', + 'hackers', + 'sponsor', + 'sponsors'], + 'message': 'Your time = Real impact! ā±ļø\n' + '\n' + "Every hour you contribute creates ripple effects in nonprofit communities. Don't let " + 'your impact go uncounted!\n' + '\n' + 'šŸ“Š Track at: https://www.ohack.dev/volunteer/track\n' + '\n' + 'Why track?\n' + '• Celebrate your contribution\n' + '• Show sponsors our collective power\n' + '• Inspire others to join our mission\n' + '\n' + "You're changing the world - let's measure it! šŸŒ", + 'icon': 'ā±ļø'}, + {'id': 'hacker_team_reminder', + 'title': 'Team Formation Reminder', + 'category_key': 'FOLLOW_UP', + 'category': 'Follow-up & Information', + 'applicable_roles': ['hacker', 'hackers'], + 'message': 'Ready to find your dream team? šŸ‘„\n' + '\n' + 'The best solutions come from diverse minds working together!\n' + '\n' + 'šŸŽÆ Team tips:\n' + '• 2-6 members work best\n' + '• Mix skills: code + design + strategy\n' + '• Track your journey: https://www.ohack.dev/volunteer/track\n' + '\n' + 'Team formation activities start soon. Prepare to meet your future collaborators! ⚔', + 'icon': 'šŸ‘„'}, + {'id': 'community_announcement', + 'title': 'Community Announcement', + 'category_key': 'COMMUNITY', + 'category': 'Community Communications', + 'applicable_roles': ['community members', 'community', 'slack'], + 'message': 'Hello Opportunity Hack Community! 🌟\n' + '\n' + 'We have some exciting news to share with all of our amazing community members who ' + 'make our mission possible.\n' + '\n' + 'šŸ“¢ [Your announcement here]\n' + '\n' + 'šŸ™ Thank you for being part of our community and helping us create lasting impact for ' + 'nonprofits through technology.\n' + '\n' + 'šŸ’¬ Join the discussion on Slack\n' + '🌐 Stay updated: https://www.ohack.dev\n' + 'šŸ“± Follow us: @opportunityhack on all socials\n' + '\n' + "Together, we're changing the world! šŸ’«", + 'icon': 'šŸ“¢'}, + {'id': 'community_newsletter', + 'title': 'Community Newsletter', + 'category_key': 'COMMUNITY', + 'category': 'Community Communications', + 'applicable_roles': ['community members', 'community', 'slack'], + 'message': 'šŸ“§ Opportunity Hack Community Update\n' + '\n' + 'Hello amazing community members! šŸ‘‹\n' + '\n' + "Here's what's been happening in our community:\n" + '\n' + 'šŸŽÆ **Recent Impact:**\n' + '• [Add recent achievements]\n' + '• [Add project highlights]\n' + '• [Add community milestones]\n' + '\n' + 'šŸ“… **Upcoming Events:**\n' + '• [Add upcoming hackathons]\n' + '• [Add mentorship opportunities]\n' + '• [Add community meetings]\n' + '\n' + '🌟 **Community Spotlight:**\n' + '[Highlight a community member, project, or nonprofit]\n' + '\n' + 'šŸ“š **Resources & Opportunities:**\n' + '• Track your volunteer hours: https://www.ohack.dev/volunteer/track\n' + '• Explore our projects: https://www.ohack.dev\n' + '• Join discussions on Slack\n' + '\n' + 'šŸ’™ Thank you for being part of our mission to create lasting technology solutions for ' + 'nonprofits!\n' + '\n' + 'Stay connected: @opportunityhack on all socials', + 'icon': 'šŸ“°'}, + {'id': 'community_event_reminder', + 'title': 'Event Reminder', + 'category_key': 'COMMUNITY', + 'category': 'Community Communications', + 'applicable_roles': ['community members', 'community', 'slack'], + 'message': "ā° Don't Miss Out! Event Reminder\n" + '\n' + 'Hey community! Just a friendly reminder about our upcoming event:\n' + '\n' + 'šŸ“… **[EVENT NAME]**\n' + 'šŸ—“ļø Date: [DATE]\n' + 'ā° Time: [TIME]\n' + 'šŸ“ Location: [LOCATION/VIRTUAL LINK]\n' + '\n' + 'šŸŽÆ **What to Expect:**\n' + '• [Add event highlights]\n' + '• [Add what attendees will learn/do]\n' + '• [Add networking opportunities]\n' + '\n' + 'šŸš€ **How to Join:**\n' + '[Add registration/join information]\n' + '\n' + 'šŸ’” **Why Attend:**\n' + '• Make a real impact for nonprofits\n' + '• Learn new technologies\n' + '• Meet like-minded changemakers\n' + '• Build your portfolio\n' + '\n' + 'ā±ļø Track your volunteer hours: https://www.ohack.dev/volunteer/track\n' + '\n' + 'See you there! 🌟\n' + '\n' + 'Questions? Reply to this email or ask in Slack.\n' + '\n' + 'Stay connected: @opportunityhack on all socials', + 'icon': 'šŸ“…'}, + {'id': 'community_thanks', + 'title': 'Community Appreciation', + 'category_key': 'COMMUNITY', + 'category': 'Community Communications', + 'applicable_roles': ['community members', 'community', 'slack'], + 'message': 'šŸ™ A Heartfelt Thank You to Our Amazing Community!\n' + '\n' + 'Dear Opportunity Hack Community,\n' + '\n' + 'We wanted to take a moment to express our genuine gratitude for each and every one ' + "of you. Whether you're a developer, designer, project manager, mentor, or nonprofit " + 'advocate - you are the heart of our mission.\n' + '\n' + 'šŸ’« **Your Impact:**\n' + '• [Add specific community achievements]\n' + '• [Add nonprofit success stories]\n' + '• [Add volunteer hour milestones]\n' + '\n' + '🌟 **What Makes You Special:**\n' + '• Your passion for social good\n' + '• Your technical expertise shared freely\n' + '• Your dedication to helping nonprofits\n' + '• Your collaborative spirit\n' + '\n' + 'šŸ“ˆ **Looking Ahead:**\n' + "Together, we're building a future where technology serves humanity. Every line of " + 'code, every design element, every mentoring session creates ripples of positive ' + 'change.\n' + '\n' + 'ā±ļø Track your volunteer hours: https://www.ohack.dev/volunteer/track\n' + '\n' + 'šŸ’¬ Keep the conversations going on Slack - we love seeing your ideas and ' + 'collaborations!\n' + '\n' + 'With immense gratitude,\n' + 'The Opportunity Hack Team šŸ’™\n' + '\n' + 'Stay connected: @opportunityhack on all socials', + 'icon': 'šŸ’'}] diff --git a/services/email_templates_service.py b/services/email_templates_service.py new file mode 100644 index 0000000..18c3d5c --- /dev/null +++ b/services/email_templates_service.py @@ -0,0 +1,265 @@ +"""Admin-managed email/message templates with version history. + +Collection layout: + email_templates/{template_id} + - title, category, category_key, applicable_roles[], message, icon + - status: "active" | "archived" + - origin: "seed" | "admin" + - version: int (current version number) + - created_at, created_by, updated_at, updated_by + email_templates/{template_id}/versions/{000N} + - full content snapshot per version (append-only; includes the current one) + - version, title, category, category_key, applicable_roles, message, icon + - updated_at, updated_by, change_note + +Revert never rewrites history: it copies an old version's content forward as a +brand-new version. The seed data (the original hardcoded frontend templates) +lives in email_templates_seed.py and is inserted on first list call so the +database always retains the originals as version 1. +""" + +import re +from datetime import datetime + +from common.log import get_logger +from db.db import get_db +from api.messages.message import Message +from services.email_templates_seed import DEFAULT_EMAIL_TEMPLATES + +logger = get_logger("email_templates_service") + +COLLECTION = "email_templates" + +_CONTENT_KEYS = ("title", "category", "category_key", "applicable_roles", "message", "icon") +_ALLOWED_PATCH_KEYS = set(_CONTENT_KEYS) | {"status", "change_note"} + +SEED_ACTOR = {"propel_user_id": None, "email": "system@ohack.org", "name": "System (seed)"} + + +def _now_iso(): + return datetime.now().isoformat() + + +def _version_doc_id(version): + return f"{version:06d}" + + +def _snapshot_from(doc_dict): + return {k: doc_dict.get(k) for k in _CONTENT_KEYS} + + +def _write_version(doc_ref, doc_dict, version, actor, change_note): + snapshot = _snapshot_from(doc_dict) + snapshot.update( + { + "version": version, + "updated_at": _now_iso(), + "updated_by": actor or {}, + "change_note": change_note or "", + } + ) + doc_ref.collection("versions").document(_version_doc_id(version)).set(snapshot) + + +def _slugify(title): + slug = re.sub(r"[^a-z0-9]+", "_", (title or "").lower()).strip("_") + return slug[:60] or "template" + + +def _doc_with_id(doc): + d = doc.to_dict() + d["id"] = doc.id + return d + + +def seed_default_templates(db=None): + """Insert any DEFAULT_EMAIL_TEMPLATES missing from the collection. + + Existing docs are never touched, so admin edits and deletions of + non-default templates survive. Returns the number of templates inserted. + """ + db = db or get_db() + inserted = 0 + for tpl in DEFAULT_EMAIL_TEMPLATES: + doc_ref = db.collection(COLLECTION).document(tpl["id"]) + if doc_ref.get().exists: + continue + now = _now_iso() + doc = { + "title": tpl["title"], + "category": tpl["category"], + "category_key": tpl["category_key"], + "applicable_roles": tpl["applicable_roles"], + "message": tpl["message"], + "icon": tpl.get("icon", ""), + "status": "active", + "origin": "seed", + "version": 1, + "created_at": now, + "created_by": SEED_ACTOR, + "updated_at": now, + "updated_by": SEED_ACTOR, + } + doc_ref.set(doc) + _write_version(doc_ref, doc, 1, SEED_ACTOR, "Imported from hardcoded messageTemplates.js") + inserted += 1 + if inserted: + logger.info(f"seed_default_templates: inserted {inserted} default templates") + return inserted + + +def admin_list_templates(): + """List all templates (any status). Auto-seeds the originals when empty.""" + db = get_db() + docs = list(db.collection(COLLECTION).stream()) + if not docs: + seed_default_templates(db) + docs = list(db.collection(COLLECTION).stream()) + results = sorted( + (_doc_with_id(d) for d in docs), + key=lambda t: (t.get("category_key") or "", t.get("title") or ""), + ) + return Message(results) + + +def admin_seed_templates(): + """Explicit 'restore defaults' — re-inserts any missing seed templates.""" + inserted = seed_default_templates() + msg = Message(f"Restored {inserted} default template(s)") + msg.inserted = inserted + return msg, 200 + + +def admin_create_template(json_in, actor): + json_in = json_in or {} + title = (json_in.get("title") or "").strip() + message_body = json_in.get("message") or "" + if not title or not message_body.strip(): + return Message("Both title and message are required"), 400 + + db = get_db() + base_slug = _slugify(title) + slug = base_slug + suffix = 2 + while db.collection(COLLECTION).document(slug).get().exists: + slug = f"{base_slug}_{suffix}" + suffix += 1 + + now = _now_iso() + doc = { + "title": title, + "category": json_in.get("category") or "Custom", + "category_key": json_in.get("category_key") or "CUSTOM", + "applicable_roles": json_in.get("applicable_roles") or [], + "message": message_body, + "icon": json_in.get("icon") or "āœ‰ļø", + "status": "active", + "origin": "admin", + "version": 1, + "created_at": now, + "created_by": actor or {}, + "updated_at": now, + "updated_by": actor or {}, + } + doc_ref = db.collection(COLLECTION).document(slug) + doc_ref.set(doc) + _write_version(doc_ref, doc, 1, actor, json_in.get("change_note") or "Created") + + msg = Message("Created template") + msg.id = slug + return msg, 201 + + +def admin_update_template(template_id, json_in, actor): + patch = {k: v for k, v in (json_in or {}).items() if k in _ALLOWED_PATCH_KEYS} + if not patch: + return Message("No allowed fields in payload"), 400 + if "title" in patch and not (patch["title"] or "").strip(): + return Message("Title cannot be empty"), 400 + if "message" in patch and not (patch["message"] or "").strip(): + return Message("Message cannot be empty"), 400 + + db = get_db() + doc_ref = db.collection(COLLECTION).document(template_id) + snap = doc_ref.get() + if not snap.exists: + return Message("Not found"), 404 + + current = snap.to_dict() + change_note = patch.pop("change_note", "") + content_changed = any(k in patch and patch[k] != current.get(k) for k in _CONTENT_KEYS) + + new_version = current.get("version", 1) + merged = {**current, **patch} + if content_changed: + new_version += 1 + merged["version"] = new_version + merged["updated_at"] = _now_iso() + merged["updated_by"] = actor or {} + + doc_ref.set(merged) + if content_changed: + _write_version(doc_ref, merged, new_version, actor, change_note) + + msg = Message("Updated template") + msg.id = template_id + msg.version = new_version + return msg, 200 + + +def admin_delete_template(template_id): + """Hard delete a template doc and its version history.""" + db = get_db() + doc_ref = db.collection(COLLECTION).document(template_id) + if not doc_ref.get().exists: + return Message("Not found"), 404 + for version_doc in doc_ref.collection("versions").stream(): + version_doc.reference.delete() + doc_ref.delete() + logger.info(f"admin_delete_template: removed {template_id}") + return Message("Deleted template"), 200 + + +def admin_get_template_versions(template_id): + db = get_db() + doc_ref = db.collection(COLLECTION).document(template_id) + if not doc_ref.get().exists: + return Message("Not found"), 404 + versions = [v.to_dict() for v in doc_ref.collection("versions").stream()] + versions.sort(key=lambda v: v.get("version", 0), reverse=True) + return Message(versions), 200 + + +def admin_revert_template(template_id, json_in, actor): + target_version = (json_in or {}).get("version") + if not isinstance(target_version, int) or target_version < 1: + return Message("A version (int) to revert to is required"), 400 + + db = get_db() + doc_ref = db.collection(COLLECTION).document(template_id) + snap = doc_ref.get() + if not snap.exists: + return Message("Not found"), 404 + + version_snap = doc_ref.collection("versions").document(_version_doc_id(target_version)).get() + if not version_snap.exists: + return Message(f"Version {target_version} not found"), 404 + + current = snap.to_dict() + current_version = current.get("version", 1) + if target_version == current_version: + return Message("Already at that version"), 400 + + new_version = current_version + 1 + merged = {**current, **_snapshot_from(version_snap.to_dict())} + merged["version"] = new_version + merged["updated_at"] = _now_iso() + merged["updated_by"] = actor or {} + + doc_ref.set(merged) + _write_version(doc_ref, merged, new_version, actor, f"Reverted to version {target_version}") + + msg = Message("Reverted template") + msg.id = template_id + msg.version = new_version + return msg, 200