Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Production moved from on-prem VM 249 (10.22.68.249) to OVH VPS (57.128.200.27, inpi-vps-waw01). Updated ALL documentation, slash commands, memory files, architecture docs, and deploy procedures. Added |local_time Jinja filter (UTC→Europe/Warsaw) and converted 155 .strftime() calls across 71 templates so timestamps display in Polish timezone regardless of server timezone. Also includes: created_by_id tracking, abort import fix, ICS calendar fix for missing end times, Pros Poland data cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1219 lines
44 KiB
Markdown
1219 lines
44 KiB
Markdown
# Messaging System Enhancements — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add 6 incremental improvements to the `/wiadomosci` messaging system — flash messages, form hints, read receipts, recipient preview, branded email template, and file attachments.
|
|
|
|
**Architecture:** Each feature is independent and deployed as a separate commit. Features 1-4 are pure template/route changes requiring no DB migrations. Feature 5 refactors email sending into the branded template service. Feature 6 introduces a new `MessageAttachment` model with file upload support.
|
|
|
|
**Tech Stack:** Flask 3.0, SQLAlchemy 2.0, Jinja2, PostgreSQL, Microsoft Graph API (email), vanilla JS
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-11-messaging-enhancements-design.md`
|
|
|
|
---
|
|
|
|
## Chunk 1: Features 1-3 (Flash, Hint, Read Receipts)
|
|
|
|
### Task 1: Post-Send Flash Message
|
|
|
|
**Files:**
|
|
- Modify: `blueprints/messages/routes.py:226` (messages_send flash)
|
|
- Modify: `blueprints/messages/routes.py:319` (messages_reply flash)
|
|
|
|
- [ ] **Step 1: Update flash in messages_send()**
|
|
|
|
Replace line 226 in `blueprints/messages/routes.py`:
|
|
|
|
```python
|
|
# OLD:
|
|
flash('Wiadomość wysłana.', 'success')
|
|
|
|
# NEW:
|
|
if recipient.notify_email_messages != False and recipient.email:
|
|
flash('Wiadomość wysłana! Odbiorca zostanie powiadomiony emailem.', 'success')
|
|
else:
|
|
flash('Wiadomość wysłana!', 'success')
|
|
```
|
|
|
|
Note: The `recipient` variable is already available in scope (line 155). The email notification check (line 197) uses the same condition, so this is consistent.
|
|
|
|
- [ ] **Step 2: Update flash in messages_reply()**
|
|
|
|
Replace line 319 in `blueprints/messages/routes.py`. The reply route needs access to the recipient user object — `original` is already queried (line 289), and `recipient_id` is computed (line 298), but the actual `User` object isn't loaded. Add a query:
|
|
|
|
```python
|
|
# After line 308 (after block_exists check), add:
|
|
recipient = db.query(User).filter(User.id == recipient_id).first()
|
|
|
|
# Then replace line 319:
|
|
# OLD:
|
|
flash('Odpowiedź wysłana.', 'success')
|
|
|
|
# NEW:
|
|
if recipient and recipient.notify_email_messages != False and recipient.email:
|
|
flash('Odpowiedź wysłana! Odbiorca zostanie powiadomiony emailem.', 'success')
|
|
else:
|
|
flash('Odpowiedź wysłana!', 'success')
|
|
```
|
|
|
|
Note: `User` import is already at the top of the file (line 5). The recipient query is also needed later for Feature 5 (email notification in reply).
|
|
|
|
- [ ] **Step 3: Test manually on dev**
|
|
|
|
Run: `python3 app.py`
|
|
|
|
1. Send a message to a user with `notify_email_messages=True` — verify flash says "...powiadomiony emailem"
|
|
2. Send a message to a user with `notify_email_messages=False` — verify flash says only "Wiadomość wysłana!"
|
|
3. Reply to a message — verify same conditional behavior
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add blueprints/messages/routes.py
|
|
git commit -m "feat(messages): add conditional post-send flash with email notification info"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Contextual Hint on Compose Form
|
|
|
|
**Files:**
|
|
- Modify: `templates/messages/compose.html:357` (after form-actions div, before `</form>`)
|
|
|
|
- [ ] **Step 1: Add hint text to compose.html**
|
|
|
|
In `templates/messages/compose.html`, add hint text after the `</div>` on line 357 (closing `.form-actions`) and before `</form>` on line 358:
|
|
|
|
```html
|
|
</div>
|
|
|
|
<div class="form-hint" style="text-align: center; margin-top: 12px; padding: 8px 16px; font-size: var(--font-size-sm); color: var(--text-secondary);">
|
|
📧 Odbiorca zostanie powiadomiony o nowej wiadomości emailem
|
|
</div>
|
|
</form>
|
|
```
|
|
|
|
- [ ] **Step 2: Verify visually on dev**
|
|
|
|
Run: `python3 app.py`
|
|
|
|
Navigate to `/wiadomosci/nowa` — verify the hint text appears below the submit button, styled as muted secondary text.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add templates/messages/compose.html
|
|
git commit -m "feat(messages): add email notification hint on compose form"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Read Receipts in Sent Box
|
|
|
|
**Files:**
|
|
- Modify: `templates/messages/sent.html:128-131` (CSS) and `templates/messages/sent.html:211-213` (indicator)
|
|
|
|
- [ ] **Step 1: Update CSS for read receipts**
|
|
|
|
In `templates/messages/sent.html`, replace the `.read-status` CSS block (lines 128-131):
|
|
|
|
```css
|
|
/* OLD: */
|
|
.read-status {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--success);
|
|
}
|
|
|
|
/* NEW: */
|
|
.read-status {
|
|
font-size: var(--font-size-xs);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.read-status.is-read {
|
|
color: var(--primary);
|
|
}
|
|
.read-status.is-unread {
|
|
color: var(--text-secondary);
|
|
}
|
|
.read-status svg {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update read indicator in message list**
|
|
|
|
Replace lines 211-213 in `templates/messages/sent.html`:
|
|
|
|
```html
|
|
<!-- OLD: -->
|
|
{% if msg.is_read %}
|
|
<div class="read-status">Przeczytana</div>
|
|
{% endif %}
|
|
|
|
<!-- NEW: -->
|
|
{% if msg.is_read %}
|
|
<div class="read-status is-read" title="Przeczytana {{ msg.read_at.strftime('%d.%m.%Y %H:%M') if msg.read_at else '' }}">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 7l-8 8-4-4"/><path d="M22 7l-8 8-1.5-1.5" opacity="0.6"/></svg>
|
|
Przeczytana
|
|
</div>
|
|
{% else %}
|
|
<div class="read-status is-unread">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 7l-8 8-4-4"/></svg>
|
|
Wysłana
|
|
</div>
|
|
{% endif %}
|
|
```
|
|
|
|
The double checkmark SVG (two overlapping check paths) indicates read, single checkmark indicates sent. The `title` attribute on read messages shows the `read_at` timestamp on hover. No new DB queries needed — `is_read` and `read_at` are already on the `PrivateMessage` model (database.py:2273-2274).
|
|
|
|
- [ ] **Step 3: Verify on dev**
|
|
|
|
Run: `python3 app.py`
|
|
|
|
1. Navigate to `/wiadomosci/wyslane` — verify all sent messages show either single gray checkmark "Wysłana" or double blue checkmark "Przeczytana"
|
|
2. Hover over read messages — verify `read_at` timestamp appears as tooltip
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add templates/messages/sent.html
|
|
git commit -m "feat(messages): add read receipt indicators in sent box"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 2: Feature 4 (Recipient Profile Preview)
|
|
|
|
### Task 4: Extend User Query with Company Data
|
|
|
|
**Files:**
|
|
- Modify: `blueprints/messages/routes.py:96-100` (messages_new user query)
|
|
|
|
- [ ] **Step 1: Add LEFT JOIN to Company**
|
|
|
|
Replace lines 96-100 in `blueprints/messages/routes.py`. The current query:
|
|
|
|
```python
|
|
users = db.query(User).filter(
|
|
User.is_active == True,
|
|
User.is_verified == True,
|
|
User.id != current_user.id
|
|
).order_by(User.name).all()
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```python
|
|
from sqlalchemy import func
|
|
from database import UserCompanyPermissions, Company
|
|
|
|
# Query users with their primary company info
|
|
users_with_companies = db.query(
|
|
User,
|
|
Company.name.label('company_name'),
|
|
Company.slug.label('company_slug'),
|
|
UserCompanyPermissions.position.label('position')
|
|
).outerjoin(
|
|
UserCompanyPermissions,
|
|
(UserCompanyPermissions.user_id == User.id)
|
|
).outerjoin(
|
|
Company,
|
|
(Company.id == UserCompanyPermissions.company_id) & (Company.status == 'active')
|
|
).filter(
|
|
User.is_active == True,
|
|
User.is_verified == True,
|
|
User.id != current_user.id
|
|
).order_by(User.name).all()
|
|
|
|
# Deduplicate users (one user may have multiple company permissions)
|
|
seen_ids = set()
|
|
users = []
|
|
for user, company_name, company_slug, position in users_with_companies:
|
|
if user.id not in seen_ids:
|
|
seen_ids.add(user.id)
|
|
user._company_name = company_name
|
|
user._company_slug = company_slug
|
|
user._position = position
|
|
users.append(user)
|
|
```
|
|
|
|
Note: `UserCompanyPermissions` and `Company` may need importing at the top of the file. Check existing imports first — `Company` may already be imported. The `func` import from sqlalchemy is for safety but not strictly needed here.
|
|
|
|
- [ ] **Step 2: Verify query returns data on dev**
|
|
|
|
Run `python3 app.py`, navigate to `/wiadomosci/nowa`, open browser dev tools → check the users JSON in page source to confirm company data fields will be present (after Step 3).
|
|
|
|
- [ ] **Step 3: Commit query change**
|
|
|
|
```bash
|
|
git add blueprints/messages/routes.py
|
|
git commit -m "feat(messages): extend user query with company data for recipient preview"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add Preview Card to Compose Template
|
|
|
|
**Files:**
|
|
- Modify: `templates/messages/compose.html` (user JSON, preview card HTML, JS)
|
|
|
|
- [ ] **Step 1: Extend user JSON in template**
|
|
|
|
In `templates/messages/compose.html`, update the users JS array (lines 366-369). Change from:
|
|
|
|
```javascript
|
|
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, showEmail: {{ 'true' if user.privacy_show_email != False else 'false' }}}{{ ',' if not loop.last }}
|
|
```
|
|
|
|
To:
|
|
|
|
```javascript
|
|
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, showEmail: {{ 'true' if user.privacy_show_email != False else 'false' }}, companyName: {{ (user._company_name or '') | tojson }}, companySlug: {{ (user._company_slug or '') | tojson }}, position: {{ (user._position or '') | tojson }}}{{ ',' if not loop.last }}
|
|
```
|
|
|
|
- [ ] **Step 2: Add preview card HTML**
|
|
|
|
In `templates/messages/compose.html`, add preview card HTML after the `#recipient-selected` div. Find the recipient-selected div (around line 330-335) and add below it:
|
|
|
|
```html
|
|
<div id="recipient-preview" style="display: none; margin-top: 8px; padding: 12px 16px; background: var(--bg-secondary); border-radius: var(--radius); border: 1px solid var(--border-color);">
|
|
<div style="display: flex; align-items: center; gap: 12px;">
|
|
<div id="preview-avatar" style="width: 40px; height: 40px; border-radius: 50%; background: var(--primary); color: white; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 16px;"></div>
|
|
<div>
|
|
<div id="preview-name" style="font-weight: 600; color: var(--text-primary);"></div>
|
|
<div id="preview-company" style="font-size: var(--font-size-sm); color: var(--text-secondary);"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 3: Update selectRecipient() JS function**
|
|
|
|
In the `selectRecipient` function (line 408), add preview card population. After `searchInput.value = '';` (line 416), add:
|
|
|
|
```javascript
|
|
// Show recipient preview card
|
|
var user = users.find(function(u) { return u.id === id; });
|
|
var previewDiv = document.getElementById('recipient-preview');
|
|
if (user && (user.companyName)) {
|
|
document.getElementById('preview-avatar').textContent = (name || email)[0].toUpperCase();
|
|
document.getElementById('preview-name').textContent = name;
|
|
var companyHtml = '';
|
|
if (user.companyName) {
|
|
companyHtml = user.companySlug
|
|
? '<a href="/firma/' + user.companySlug + '" target="_blank" style="color: var(--primary); text-decoration: none;">' + user.companyName + '</a>'
|
|
: user.companyName;
|
|
}
|
|
if (user.position) {
|
|
companyHtml = user.position + (companyHtml ? ' · ' + companyHtml : '');
|
|
}
|
|
document.getElementById('preview-company').innerHTML = companyHtml;
|
|
previewDiv.style.display = 'block';
|
|
} else {
|
|
previewDiv.style.display = 'none';
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Update clearRecipient() to hide preview**
|
|
|
|
In `clearRecipient()` (line 419), add after `searchInput.focus();` (line 424):
|
|
|
|
```javascript
|
|
document.getElementById('recipient-preview').style.display = 'none';
|
|
```
|
|
|
|
- [ ] **Step 5: Verify on dev**
|
|
|
|
Run `python3 app.py`, navigate to `/wiadomosci/nowa`:
|
|
1. Type a user name in the autocomplete
|
|
2. Select a user — verify preview card appears with avatar, name, and company
|
|
3. Click "x" to clear — verify preview card disappears
|
|
4. Select a user without company — verify no preview card shown
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add templates/messages/compose.html
|
|
git commit -m "feat(messages): add recipient profile preview card on compose form"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 3: Feature 5 (Branded Email Template)
|
|
|
|
### Task 6: Create Branded Email Helper
|
|
|
|
**Files:**
|
|
- Modify: `email_service.py` (new helper function after `_email_v3_wrap`)
|
|
|
|
- [ ] **Step 1: Add helper function to email_service.py**
|
|
|
|
After the `_email_v3_wrap()` function (after line 381), add:
|
|
|
|
```python
|
|
def build_message_notification_email(sender_name: str, subject: str, content_preview: str, message_url: str, settings_url: str) -> tuple:
|
|
"""Build branded email for message notification. Returns (html, text)."""
|
|
subject_html = f'<p style="margin:0 0 16px; color:#475569; font-size:15px;"><strong>Temat:</strong> {subject}</p>' if subject else ''
|
|
|
|
content_html = f'''
|
|
<p style="margin:0 0 20px; color:#1e293b; font-size:16px;">
|
|
<strong>{sender_name}</strong> wysłał(a) Ci wiadomość na portalu Norda Biznes.
|
|
</p>
|
|
|
|
{subject_html}
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-left:4px solid #3b82f6; border-radius:4px; margin-bottom:24px;">
|
|
<tr><td style="padding:16px;">
|
|
<p style="margin:0; color:#374151; font-size:14px;">{content_preview}</p>
|
|
</td></tr>
|
|
</table>
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
|
|
<tr><td align="center" style="padding:8px 0;">
|
|
<a href="{message_url}" style="display:inline-block; padding:14px 36px; background-color:#3b82f6; color:#ffffff; text-decoration:none; border-radius:8px; font-size:15px; font-weight:600;">Przeczytaj wiadomość</a>
|
|
</td></tr>
|
|
</table>
|
|
|
|
<p style="margin:0; color:#94a3b8; font-size:12px; text-align:center;">
|
|
Możesz wyłączyć powiadomienia e-mail w <a href="{settings_url}" style="color:#2563eb;">ustawieniach prywatności</a>.
|
|
</p>
|
|
'''
|
|
|
|
html = _email_v3_wrap('Nowa wiadomość', f'od {sender_name}', content_html)
|
|
|
|
text = f'{sender_name} wysłał(a) Ci wiadomość na portalu Norda Biznes. Odczytaj: {message_url}'
|
|
|
|
return html, text
|
|
```
|
|
|
|
- [ ] **Step 2: Commit helper**
|
|
|
|
```bash
|
|
git add email_service.py
|
|
git commit -m "feat(email): add branded message notification email builder"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Replace Inline HTML in messages_send()
|
|
|
|
**Files:**
|
|
- Modify: `blueprints/messages/routes.py:197-224` (email sending block in messages_send)
|
|
|
|
- [ ] **Step 1: Import the new helper**
|
|
|
|
At the top of `blueprints/messages/routes.py`, add to existing imports:
|
|
|
|
```python
|
|
from email_service import send_email, build_message_notification_email
|
|
```
|
|
|
|
If `send_email` is already imported, just add `build_message_notification_email`.
|
|
|
|
- [ ] **Step 2: Replace inline HTML email block**
|
|
|
|
Replace lines 197-224 in `blueprints/messages/routes.py`:
|
|
|
|
```python
|
|
# Send email notification if recipient has it enabled
|
|
if recipient.notify_email_messages != False and recipient.email:
|
|
try:
|
|
message_url = url_for('.messages_view', message_id=message.id, _external=True)
|
|
settings_url = url_for('auth.konto_prywatnosc', _external=True)
|
|
sender_name = current_user.name or current_user.email.split('@')[0]
|
|
preview = (content[:200] + '...') if len(content) > 200 else content
|
|
subject_line = f'Nowa wiadomość od {sender_name} — Norda Biznes'
|
|
|
|
email_html, email_text = build_message_notification_email(
|
|
sender_name=sender_name,
|
|
subject=subject,
|
|
content_preview=preview,
|
|
message_url=message_url,
|
|
settings_url=settings_url
|
|
)
|
|
send_email(
|
|
to=[recipient.email],
|
|
subject=subject_line,
|
|
body_text=email_text,
|
|
body_html=email_html,
|
|
email_type='message_notification',
|
|
user_id=recipient.id,
|
|
recipient_name=recipient.name
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning(f"Failed to send message email notification: {e}")
|
|
```
|
|
|
|
- [ ] **Step 3: Verify email on dev**
|
|
|
|
Run `python3 app.py`, send a message. Check email (or logs) to verify the branded template is used — should show gradient header with Norda Biznes logo, styled content, CTA button, footer with address.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add blueprints/messages/routes.py
|
|
git commit -m "feat(messages): use branded email template for message notifications"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Add Email Notification + UserNotification to messages_reply()
|
|
|
|
**Files:**
|
|
- Modify: `blueprints/messages/routes.py:309-317` (messages_reply after reply creation)
|
|
|
|
The `messages_reply()` route currently does NOT send email notifications or create UserNotification. This is a gap — the reply sender's recipient doesn't know they got a reply unless they manually check the inbox.
|
|
|
|
- [ ] **Step 1: Add imports if needed**
|
|
|
|
Ensure these imports exist at the top of `blueprints/messages/routes.py`:
|
|
|
|
```python
|
|
from database import UserNotification # may already be imported
|
|
```
|
|
|
|
- [ ] **Step 2: Add flush, notification, and email to messages_reply()**
|
|
|
|
Replace lines 316-317 in `blueprints/messages/routes.py`:
|
|
|
|
```python
|
|
# OLD:
|
|
db.add(reply)
|
|
db.commit()
|
|
|
|
# NEW:
|
|
db.add(reply)
|
|
db.flush() # Get reply.id for notification link
|
|
|
|
# Create in-app notification
|
|
sender_name = current_user.name or current_user.email.split('@')[0]
|
|
notification = UserNotification(
|
|
user_id=recipient_id,
|
|
title=f'Nowa odpowiedź od {sender_name}',
|
|
message=f'{sender_name} odpowiedział(a) na wiadomość' + (f': {original.subject}' if original.subject else ''),
|
|
notification_type='message',
|
|
related_type='message',
|
|
related_id=reply.id,
|
|
action_url=url_for('.messages_view', message_id=message_id)
|
|
)
|
|
db.add(notification)
|
|
|
|
# Send email notification
|
|
if recipient and recipient.notify_email_messages != False and recipient.email:
|
|
try:
|
|
message_url = url_for('.messages_view', message_id=message_id, _external=True)
|
|
settings_url = url_for('auth.konto_prywatnosc', _external=True)
|
|
preview = (content[:200] + '...') if len(content) > 200 else content
|
|
subject_line = f'Nowa odpowiedź od {sender_name} — Norda Biznes'
|
|
|
|
email_html, email_text = build_message_notification_email(
|
|
sender_name=sender_name,
|
|
subject=f"Re: {original.subject}" if original.subject else None,
|
|
content_preview=preview,
|
|
message_url=message_url,
|
|
settings_url=settings_url
|
|
)
|
|
send_email(
|
|
to=[recipient.email],
|
|
subject=subject_line,
|
|
body_text=email_text,
|
|
body_html=email_html,
|
|
email_type='message_notification',
|
|
user_id=recipient_id,
|
|
recipient_name=recipient.name
|
|
)
|
|
except Exception as e:
|
|
import logging
|
|
logging.getLogger(__name__).warning(f"Failed to send reply email notification: {e}")
|
|
|
|
db.commit()
|
|
```
|
|
|
|
Note: The `recipient` variable was added in Task 1 Step 2 (queried after block_exists check). Make sure that step is done first.
|
|
|
|
- [ ] **Step 3: Test reply notification on dev**
|
|
|
|
1. Send a message, then reply as the other user
|
|
2. Verify: in-app notification created (check bell icon), email sent with branded template
|
|
3. Reply to a message where recipient has `notify_email_messages=False` — verify no email sent
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add blueprints/messages/routes.py
|
|
git commit -m "feat(messages): add email notification and in-app notification for replies"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 4: Feature 6 — File Attachments (Model + Migration)
|
|
|
|
### Task 9: Create MessageAttachment Model
|
|
|
|
**Files:**
|
|
- Modify: `database.py` (add new model after PrivateMessage class, line ~2287)
|
|
|
|
- [ ] **Step 1: Add MessageAttachment model**
|
|
|
|
After line 2287 in `database.py` (after the `PrivateMessage` class relationships), add:
|
|
|
|
```python
|
|
class MessageAttachment(Base):
|
|
"""Załączniki do wiadomości prywatnych"""
|
|
__tablename__ = 'message_attachments'
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
message_id = Column(Integer, ForeignKey('private_messages.id', ondelete='CASCADE'), nullable=False)
|
|
filename = Column(String(255), nullable=False) # original filename
|
|
stored_filename = Column(String(255), nullable=False) # UUID-based on disk
|
|
file_size = Column(Integer, nullable=False) # bytes
|
|
mime_type = Column(String(100), nullable=False)
|
|
created_at = Column(DateTime, default=datetime.now)
|
|
|
|
message = relationship('PrivateMessage', backref=backref('attachments', cascade='all, delete-orphan'))
|
|
```
|
|
|
|
Also add `backref` to the import from `sqlalchemy.orm` at the top of `database.py` if not already there.
|
|
|
|
- [ ] **Step 2: Verify model loads**
|
|
|
|
Run: `python3 -c "from database import MessageAttachment; print('OK')"` — should print "OK".
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add database.py
|
|
git commit -m "feat(messages): add MessageAttachment model"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Create SQL Migration
|
|
|
|
**Files:**
|
|
- Create: `database/migrations/063_message_attachments.sql`
|
|
|
|
- [ ] **Step 1: Write migration SQL**
|
|
|
|
```sql
|
|
-- Migration: 063_message_attachments.sql
|
|
-- Description: Create message_attachments table for file attachments in messaging
|
|
-- Date: 2026-03-11
|
|
|
|
CREATE TABLE IF NOT EXISTS message_attachments (
|
|
id SERIAL PRIMARY KEY,
|
|
message_id INTEGER NOT NULL REFERENCES private_messages(id) ON DELETE CASCADE,
|
|
filename VARCHAR(255) NOT NULL,
|
|
stored_filename VARCHAR(255) NOT NULL,
|
|
file_size INTEGER NOT NULL,
|
|
mime_type VARCHAR(100) NOT NULL,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_message_attachments_message_id ON message_attachments(message_id);
|
|
|
|
-- Permissions
|
|
GRANT ALL ON TABLE message_attachments TO nordabiz_app;
|
|
GRANT USAGE, SELECT ON SEQUENCE message_attachments_id_seq TO nordabiz_app;
|
|
```
|
|
|
|
- [ ] **Step 2: Check migration number**
|
|
|
|
```bash
|
|
ls database/migrations/ | tail -5
|
|
```
|
|
|
|
Verify 063 doesn't conflict. If the last migration is 063 or higher, increment accordingly.
|
|
|
|
- [ ] **Step 3: Run migration on dev**
|
|
|
|
```bash
|
|
python3 scripts/run_migration.py database/migrations/063_message_attachments.sql
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add database/migrations/063_message_attachments.sql
|
|
git commit -m "feat(messages): add message_attachments migration"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 5: Feature 6 — File Attachments (Upload Service + Routes)
|
|
|
|
### Task 11: Create Message Upload Service
|
|
|
|
**Files:**
|
|
- Create: `message_upload_service.py`
|
|
|
|
This service extends the `FileUploadService` pattern from `file_upload_service.py` but supports both images and documents.
|
|
|
|
- [ ] **Step 1: Create message_upload_service.py**
|
|
|
|
```python
|
|
"""File upload service for message attachments."""
|
|
import os
|
|
import uuid
|
|
import struct
|
|
from datetime import datetime
|
|
from werkzeug.utils import secure_filename
|
|
|
|
|
|
# Allowed file types and their limits
|
|
IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
|
DOCUMENT_EXTENSIONS = {'pdf', 'docx', 'xlsx'}
|
|
ALLOWED_EXTENSIONS = IMAGE_EXTENSIONS | DOCUMENT_EXTENSIONS
|
|
|
|
MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB per image
|
|
MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 # 10MB per document
|
|
MAX_TOTAL_SIZE = 15 * 1024 * 1024 # 15MB total per message
|
|
MAX_FILES_PER_MESSAGE = 3
|
|
|
|
# Magic bytes signatures for validation
|
|
FILE_SIGNATURES = {
|
|
'jpg': [b'\xff\xd8\xff'],
|
|
'jpeg': [b'\xff\xd8\xff'],
|
|
'png': [b'\x89PNG\r\n\x1a\n'],
|
|
'gif': [b'GIF87a', b'GIF89a'],
|
|
'pdf': [b'%PDF'],
|
|
'docx': [b'PK\x03\x04'], # ZIP-based format
|
|
'xlsx': [b'PK\x03\x04'], # ZIP-based format
|
|
}
|
|
|
|
MIME_TYPES = {
|
|
'jpg': 'image/jpeg',
|
|
'jpeg': 'image/jpeg',
|
|
'png': 'image/png',
|
|
'gif': 'image/gif',
|
|
'pdf': 'application/pdf',
|
|
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
}
|
|
|
|
UPLOAD_BASE = 'static/uploads/messages'
|
|
|
|
|
|
class MessageUploadService:
|
|
"""Handles file uploads for message attachments."""
|
|
|
|
def __init__(self, app_root: str):
|
|
self.app_root = app_root
|
|
|
|
def validate_files(self, files: list) -> tuple:
|
|
"""Validate list of uploaded files. Returns (valid_files, errors)."""
|
|
errors = []
|
|
valid = []
|
|
|
|
if len(files) > MAX_FILES_PER_MESSAGE:
|
|
errors.append(f'Maksymalnie {MAX_FILES_PER_MESSAGE} pliki na wiadomość.')
|
|
return [], errors
|
|
|
|
total_size = 0
|
|
for f in files:
|
|
if not f or not f.filename:
|
|
continue
|
|
|
|
filename = secure_filename(f.filename)
|
|
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
|
|
|
if ext not in ALLOWED_EXTENSIONS:
|
|
errors.append(f'Niedozwolony typ pliku: {filename}')
|
|
continue
|
|
|
|
# Read file content for size + magic bytes check
|
|
content = f.read()
|
|
f.seek(0)
|
|
size = len(content)
|
|
|
|
max_size = MAX_IMAGE_SIZE if ext in IMAGE_EXTENSIONS else MAX_DOCUMENT_SIZE
|
|
if size > max_size:
|
|
limit_mb = max_size // (1024 * 1024)
|
|
errors.append(f'Plik {filename} przekracza limit {limit_mb}MB.')
|
|
continue
|
|
|
|
total_size += size
|
|
|
|
# Magic bytes validation
|
|
if ext in FILE_SIGNATURES:
|
|
valid_sig = False
|
|
for sig in FILE_SIGNATURES[ext]:
|
|
if content[:len(sig)] == sig:
|
|
valid_sig = True
|
|
break
|
|
if not valid_sig:
|
|
errors.append(f'Plik {filename} ma nieprawidłowy format.')
|
|
continue
|
|
|
|
valid.append((f, filename, ext, size, content))
|
|
|
|
if total_size > MAX_TOTAL_SIZE:
|
|
errors.append(f'Łączny rozmiar plików przekracza {MAX_TOTAL_SIZE // (1024*1024)}MB.')
|
|
return [], errors
|
|
|
|
return valid, errors
|
|
|
|
def save_file(self, content: bytes, ext: str) -> tuple:
|
|
"""Save file to disk. Returns (stored_filename, relative_path)."""
|
|
now = datetime.now()
|
|
subdir = os.path.join(UPLOAD_BASE, str(now.year), f'{now.month:02d}')
|
|
full_dir = os.path.join(self.app_root, subdir)
|
|
os.makedirs(full_dir, exist_ok=True)
|
|
|
|
stored_filename = f'{uuid.uuid4().hex}.{ext}'
|
|
full_path = os.path.join(full_dir, stored_filename)
|
|
|
|
with open(full_path, 'wb') as out:
|
|
out.write(content)
|
|
|
|
relative_path = os.path.join(subdir, stored_filename)
|
|
return stored_filename, relative_path
|
|
|
|
def get_mime_type(self, ext: str) -> str:
|
|
"""Get MIME type for extension."""
|
|
return MIME_TYPES.get(ext, 'application/octet-stream')
|
|
|
|
def is_image(self, ext: str) -> bool:
|
|
"""Check if extension is an image type."""
|
|
return ext in IMAGE_EXTENSIONS
|
|
|
|
def delete_file(self, relative_path: str):
|
|
"""Delete a file from disk."""
|
|
full_path = os.path.join(self.app_root, relative_path)
|
|
if os.path.exists(full_path):
|
|
os.remove(full_path)
|
|
```
|
|
|
|
- [ ] **Step 2: Verify service loads**
|
|
|
|
Run: `python3 -c "from message_upload_service import MessageUploadService; print('OK')"`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add message_upload_service.py
|
|
git commit -m "feat(messages): add message upload service for file attachments"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Add File Upload to messages_send()
|
|
|
|
**Files:**
|
|
- Modify: `blueprints/messages/routes.py` (messages_send route)
|
|
|
|
- [ ] **Step 1: Import upload service and model**
|
|
|
|
At the top of `blueprints/messages/routes.py`, add:
|
|
|
|
```python
|
|
from message_upload_service import MessageUploadService
|
|
from database import MessageAttachment
|
|
```
|
|
|
|
- [ ] **Step 2: Add file processing to messages_send()**
|
|
|
|
After `db.flush()` (line 178, which gets `message.id`) and before the UserNotification creation (line ~180), add file processing:
|
|
|
|
```python
|
|
# Process file attachments
|
|
attachment_errors = []
|
|
if request.files.getlist('attachments'):
|
|
import os
|
|
upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
files = [f for f in request.files.getlist('attachments') if f and f.filename]
|
|
if files:
|
|
valid_files, attachment_errors = upload_service.validate_files(files)
|
|
if attachment_errors:
|
|
db.rollback()
|
|
for err in attachment_errors:
|
|
flash(err, 'error')
|
|
return redirect(url_for('.messages_new'))
|
|
|
|
for f, filename, ext, size, content in valid_files:
|
|
stored_filename, relative_path = upload_service.save_file(content, ext)
|
|
attachment = MessageAttachment(
|
|
message_id=message.id,
|
|
filename=filename,
|
|
stored_filename=stored_filename,
|
|
file_size=size,
|
|
mime_type=upload_service.get_mime_type(ext)
|
|
)
|
|
db.add(attachment)
|
|
```
|
|
|
|
- [ ] **Step 3: Similarly add to messages_reply()**
|
|
|
|
In `messages_reply()`, after `db.flush()` (added in Task 8) and before the UserNotification creation, add the same file processing block but using `reply.id` instead of `message.id`:
|
|
|
|
```python
|
|
# Process file attachments
|
|
if request.files.getlist('attachments'):
|
|
import os
|
|
upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
files = [f for f in request.files.getlist('attachments') if f and f.filename]
|
|
if files:
|
|
valid_files, attachment_errors = upload_service.validate_files(files)
|
|
if attachment_errors:
|
|
db.rollback()
|
|
for err in attachment_errors:
|
|
flash(err, 'error')
|
|
return redirect(url_for('.messages_view', message_id=message_id))
|
|
|
|
for f, filename, ext, size, content in valid_files:
|
|
stored_filename, relative_path = upload_service.save_file(content, ext)
|
|
attachment = MessageAttachment(
|
|
message_id=reply.id,
|
|
filename=filename,
|
|
stored_filename=stored_filename,
|
|
file_size=size,
|
|
mime_type=upload_service.get_mime_type(ext)
|
|
)
|
|
db.add(attachment)
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add blueprints/messages/routes.py
|
|
git commit -m "feat(messages): process file attachments on send and reply"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 6: Feature 6 — File Attachments (Templates)
|
|
|
|
### Task 13: Add File Input to Compose Form
|
|
|
|
**Files:**
|
|
- Modify: `templates/messages/compose.html`
|
|
|
|
- [ ] **Step 1: Add enctype to form tag**
|
|
|
|
Find the `<form` tag in `compose.html` and add `enctype="multipart/form-data"`. It should look like:
|
|
|
|
```html
|
|
<form method="POST" action="..." enctype="multipart/form-data">
|
|
```
|
|
|
|
- [ ] **Step 2: Add file input before form-actions**
|
|
|
|
Before the `.form-actions` div (line 348), add:
|
|
|
|
```html
|
|
<div class="form-group">
|
|
<label>Załączniki (maks. 3 pliki, 15MB łącznie)</label>
|
|
<div class="file-upload-zone" id="file-drop-zone" style="border: 2px dashed var(--border-color); border-radius: var(--radius); padding: 20px; text-align: center; cursor: pointer; transition: border-color 0.2s;">
|
|
<input type="file" name="attachments" id="file-input" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="display: none;">
|
|
<p style="margin: 0; color: var(--text-secondary); font-size: var(--font-size-sm);">
|
|
Przeciągnij pliki tutaj lub <a href="#" onclick="document.getElementById('file-input').click(); return false;" style="color: var(--primary);">wybierz z dysku</a>
|
|
</p>
|
|
<p style="margin: 4px 0 0; color: var(--text-secondary); font-size: var(--font-size-xs);">
|
|
JPG, PNG, GIF (5MB) · PDF, DOCX, XLSX (10MB)
|
|
</p>
|
|
</div>
|
|
<div id="file-list" style="margin-top: 8px;"></div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 3: Add file handling JS**
|
|
|
|
In the `{% block extra_js %}` section, add file handling JavaScript (at the end of the existing IIFE or as a new block):
|
|
|
|
```javascript
|
|
// File attachment handling
|
|
(function() {
|
|
var dropZone = document.getElementById('file-drop-zone');
|
|
var fileInput = document.getElementById('file-input');
|
|
var fileList = document.getElementById('file-list');
|
|
if (!dropZone) return;
|
|
|
|
dropZone.addEventListener('click', function(e) {
|
|
if (e.target.tagName !== 'A') fileInput.click();
|
|
});
|
|
|
|
dropZone.addEventListener('dragover', function(e) {
|
|
e.preventDefault();
|
|
dropZone.style.borderColor = 'var(--primary)';
|
|
});
|
|
|
|
dropZone.addEventListener('dragleave', function() {
|
|
dropZone.style.borderColor = 'var(--border-color)';
|
|
});
|
|
|
|
dropZone.addEventListener('drop', function(e) {
|
|
e.preventDefault();
|
|
dropZone.style.borderColor = 'var(--border-color)';
|
|
var dt = new DataTransfer();
|
|
Array.from(e.dataTransfer.files).forEach(function(f) { dt.items.add(f); });
|
|
Array.from(fileInput.files).forEach(function(f) { dt.items.add(f); });
|
|
fileInput.files = dt.files;
|
|
updateFileList();
|
|
});
|
|
|
|
fileInput.addEventListener('change', updateFileList);
|
|
|
|
function updateFileList() {
|
|
var files = Array.from(fileInput.files);
|
|
if (files.length === 0) {
|
|
fileList.innerHTML = '';
|
|
return;
|
|
}
|
|
fileList.innerHTML = files.map(function(f, i) {
|
|
var sizeMB = (f.size / 1024 / 1024).toFixed(1);
|
|
return '<div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: var(--font-size-sm);">' +
|
|
'<span style="color: var(--text-secondary);">📎</span> ' +
|
|
'<span>' + f.name + '</span> ' +
|
|
'<span style="color: var(--text-secondary);">(' + sizeMB + ' MB)</span> ' +
|
|
'<a href="#" onclick="removeFile(' + i + '); return false;" style="color: var(--danger); margin-left: auto;">✕</a>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
window.removeFile = function(index) {
|
|
var dt = new DataTransfer();
|
|
Array.from(fileInput.files).forEach(function(f, i) {
|
|
if (i !== index) dt.items.add(f);
|
|
});
|
|
fileInput.files = dt.files;
|
|
updateFileList();
|
|
};
|
|
})();
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add templates/messages/compose.html
|
|
git commit -m "feat(messages): add file upload input to compose form"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Add File Input to Reply Form
|
|
|
|
**Files:**
|
|
- Modify: `templates/messages/view.html:233-239` (reply form)
|
|
|
|
- [ ] **Step 1: Add enctype and file input to reply form**
|
|
|
|
Replace lines 233-239 in `templates/messages/view.html`:
|
|
|
|
```html
|
|
<form method="POST" action="{{ url_for('.messages_reply', message_id=message.id) }}" enctype="multipart/form-data">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<div class="form-group">
|
|
<textarea name="content" rows="4" required placeholder="Napisz odpowiedz..."></textarea>
|
|
</div>
|
|
<div class="form-group" style="margin-top: 8px;">
|
|
<input type="file" name="attachments" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="font-size: var(--font-size-sm);">
|
|
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Maks. 3 pliki, 15MB łącznie</span>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">Wyslij odpowiedz</button>
|
|
</form>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add templates/messages/view.html
|
|
git commit -m "feat(messages): add file upload to reply form"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Display Attachments in Message View
|
|
|
|
**Files:**
|
|
- Modify: `templates/messages/view.html:226-227` (after message body)
|
|
- Modify: `blueprints/messages/routes.py` (eager load attachments in messages_view)
|
|
|
|
- [ ] **Step 1: Eager load attachments in messages_view()**
|
|
|
|
In `messages_view()` route, update the message query to eager load attachments. Find the query line and add `options(joinedload(...))`:
|
|
|
|
```python
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
# In messages_view(), update the message query:
|
|
message = db.query(PrivateMessage).options(
|
|
joinedload(PrivateMessage.attachments)
|
|
).filter(
|
|
PrivateMessage.id == message_id
|
|
).first()
|
|
```
|
|
|
|
Note: Also check if `joinedload` is already imported.
|
|
|
|
- [ ] **Step 2: Add attachment display in view.html**
|
|
|
|
After line 226 (`<div class="message-body">{{ message.content }}</div>`) in `templates/messages/view.html`, add:
|
|
|
|
```html
|
|
{% if message.attachments %}
|
|
<div class="message-attachments" style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
|
<h4 style="font-size: var(--font-size-sm); color: var(--text-secondary); margin: 0 0 8px;">Załączniki ({{ message.attachments|length }})</h4>
|
|
{% for att in message.attachments %}
|
|
{% set is_image = att.mime_type.startswith('image/') %}
|
|
<div style="display: flex; align-items: center; gap: 10px; padding: 8px 0; {% if not loop.last %}border-bottom: 1px solid var(--border-color);{% endif %}">
|
|
{% if is_image %}
|
|
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" target="_blank">
|
|
<img src="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" alt="{{ att.filename }}" style="max-width: 300px; max-height: 200px; border-radius: var(--radius-sm); border: 1px solid var(--border-color);">
|
|
</a>
|
|
{% else %}
|
|
<span style="font-size: 20px;">📄</span>
|
|
<div>
|
|
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" download="{{ att.filename }}" style="color: var(--primary); font-weight: 500;">{{ att.filename }}</a>
|
|
<span style="font-size: var(--font-size-xs); color: var(--text-secondary); margin-left: 8px;">{{ (att.file_size / 1024)|round(0)|int }} KB</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
```
|
|
|
|
- [ ] **Step 3: Test on dev**
|
|
|
|
1. Send a message with image attachment — verify inline thumbnail in view
|
|
2. Send a message with PDF — verify file icon with download link
|
|
3. Verify clicking image opens full size in new tab
|
|
4. Verify clicking document downloads with original filename
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add templates/messages/view.html blueprints/messages/routes.py
|
|
git commit -m "feat(messages): display attachments in message view"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Add Attachment Indicator to Inbox and Sent Box
|
|
|
|
**Files:**
|
|
- Modify: `templates/messages/inbox.html` (paperclip icon)
|
|
- Modify: `templates/messages/sent.html` (paperclip icon)
|
|
- Modify: `blueprints/messages/routes.py` (eager load in inbox/sent queries)
|
|
|
|
- [ ] **Step 1: Eager load attachments in messages_inbox()**
|
|
|
|
In `messages_inbox()` route, add `options(joinedload(PrivateMessage.attachments))` to the query.
|
|
|
|
- [ ] **Step 2: Eager load attachments in messages_sent()**
|
|
|
|
In `messages_sent()` route, add `options(joinedload(PrivateMessage.attachments))` to the query.
|
|
|
|
- [ ] **Step 3: Add paperclip icon to inbox.html**
|
|
|
|
In `templates/messages/inbox.html`, find the message-preview div and add after it:
|
|
|
|
```html
|
|
{% if msg.attachments %}
|
|
<span style="color: var(--text-secondary); font-size: var(--font-size-xs);" title="{{ msg.attachments|length }} załącznik{{ 'ów' if msg.attachments|length > 1 else '' }}">📎 {{ msg.attachments|length }}</span>
|
|
{% endif %}
|
|
```
|
|
|
|
- [ ] **Step 4: Add paperclip icon to sent.html**
|
|
|
|
Same pattern in `templates/messages/sent.html` — add after the message-preview div.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add templates/messages/inbox.html templates/messages/sent.html blueprints/messages/routes.py
|
|
git commit -m "feat(messages): add attachment indicator in inbox and sent box"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 7: Final Testing & Deployment
|
|
|
|
### Task 17: Full Integration Test on Dev
|
|
|
|
- [ ] **Step 1: Start dev server**
|
|
|
|
```bash
|
|
python3 app.py
|
|
```
|
|
|
|
- [ ] **Step 2: Test all 6 features end-to-end**
|
|
|
|
1. **Flash message:** Send message → verify conditional flash text
|
|
2. **Hint:** Compose form → verify email hint below button
|
|
3. **Read receipts:** Sent box → verify checkmark icons and tooltips
|
|
4. **Recipient preview:** Select recipient → verify mini business card
|
|
5. **Branded email:** Send message → verify branded email received
|
|
6. **Attachments:** Send with image + PDF → verify upload, display, download
|
|
|
|
- [ ] **Step 3: Test edge cases**
|
|
|
|
1. Send message without attachments (backward compat)
|
|
2. Send with 4+ files → verify error
|
|
3. Send with oversized file → verify error
|
|
4. Reply with attachment → verify works
|
|
5. Mobile viewport → verify file upload zone works
|
|
|
|
### Task 18: Deploy to Staging
|
|
|
|
- [ ] **Step 1: Push to remotes**
|
|
|
|
```bash
|
|
git push origin master && git push inpi master
|
|
```
|
|
|
|
- [ ] **Step 2: Deploy to staging**
|
|
|
|
```bash
|
|
ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && sudo -u www-data git pull && sudo systemctl restart nordabiznes"
|
|
```
|
|
|
|
- [ ] **Step 3: Run migration on staging**
|
|
|
|
```bash
|
|
ssh maciejpi@10.22.68.248 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/063_message_attachments.sql"
|
|
```
|
|
|
|
- [ ] **Step 4: Verify on staging**
|
|
|
|
```bash
|
|
curl -sI https://staging.nordabiznes.pl/health | head -3
|
|
```
|
|
|
|
Test messaging features manually on staging.
|
|
|
|
### Task 19: Deploy to Production
|
|
|
|
- [ ] **Step 1: Deploy**
|
|
|
|
```bash
|
|
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && sudo -u www-data git pull"
|
|
```
|
|
|
|
- [ ] **Step 2: Run migration**
|
|
|
|
```bash
|
|
ssh maciejpi@57.128.200.27 "cd /var/www/nordabiznes && /var/www/nordabiznes/venv/bin/python3 scripts/run_migration.py database/migrations/063_message_attachments.sql"
|
|
```
|
|
|
|
- [ ] **Step 3: Create upload directory**
|
|
|
|
```bash
|
|
ssh maciejpi@57.128.200.27 "mkdir -p /var/www/nordabiznes/static/uploads/messages && sudo chown -R maciejpi:maciejpi /var/www/nordabiznes/static/uploads/messages"
|
|
```
|
|
|
|
- [ ] **Step 4: Restart and verify**
|
|
|
|
```bash
|
|
ssh maciejpi@57.128.200.27 "sudo systemctl restart nordabiznes"
|
|
curl -sI https://nordabiznes.pl/health | head -3
|
|
```
|
|
|
|
- [ ] **Step 5: Update release notes**
|
|
|
|
Add new entries to `release_notes` in `blueprints/public/routes.py` following the existing style (user-facing language, no technical jargon).
|